Compare commits
389 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f05a8d92a | |||
| 69ae2b2997 | |||
| b86e47de02 | |||
| ea7647f43c | |||
| ae7df52780 | |||
| bc5737b9b0 | |||
| d0745d1914 | |||
| 2b4c926a70 | |||
| d922c1c80f | |||
| 67500a7689 | |||
| 1c8aa7440c | |||
| d128dbec4c | |||
| 676cb8810b | |||
| 189e3d5599 | |||
| 009d0b39f9 | |||
| 81a8aa7c3d | |||
| 6c6761d14b | |||
| 7d2e3df929 | |||
| f334c696cb | |||
| db974d72d5 | |||
| c15e342bb8 | |||
| dc1449c7b6 | |||
| 0b305caf58 | |||
| 8f1f3645b2 | |||
| 0079162efe | |||
| 7afec06d4c | |||
| 29f85a8fd2 | |||
| 6e0dc24eca | |||
| cee1180aa7 | |||
| 6db2b55e63 | |||
| a3c038781f | |||
| 59c9e5397e | |||
| a4c253b9a9 | |||
| f12b4faf34 | |||
| ff49759f42 | |||
| 01d0c738bc | |||
| d57554a48c | |||
| b16b57f38b | |||
| 12177446a2 | |||
| 61b15db958 | |||
| 349e8f5139 | |||
| f30482808b | |||
| 79cdecdff6 | |||
| 336dee53cd | |||
| 77022bbd7f | |||
| df96df776d | |||
| 67bc803859 | |||
| 8ef56c6d91 | |||
| d377d1e1cf | |||
| 4209e4d90d | |||
| 83c85d02ee | |||
| 866b72d029 | |||
| 4bc0f44789 | |||
| 99c55cb22f | |||
| 74c73c695f | |||
| b972891337 | |||
| 57515d54db | |||
| 0ff8dcc8e9 | |||
| 38efa6a2ba | |||
| 6306625184 | |||
| 1803ab303f | |||
| e72dd7c845 | |||
| 87288caeb9 | |||
| 79b519e462 | |||
| 5f8ea2aecc | |||
| 94bc52a0c3 | |||
| fed51bdcd9 | |||
| 5fc9689645 | |||
| 7c6a783fc8 | |||
| 764d479d7f | |||
| ec15f29e40 | |||
| 2c12bee79b | |||
| 1120866b75 | |||
| b362c069e5 | |||
| 4b6b18c182 | |||
| 80efc8c60c | |||
| 99168157fc | |||
| 23c3263562 | |||
| 1179a78fe1 | |||
| 82677ddd85 | |||
| 31f29e9086 | |||
| 3b3e606573 | |||
| 18b713cec3 | |||
| 2a6d385cea | |||
| 4cc1926899 | |||
| 15b2c2b739 | |||
| 197bf56271 | |||
| 4110f4b8ce | |||
| becbaca858 | |||
| add50257f6 | |||
| f061ee5f88 | |||
| 480b81b3dd | |||
| 61d4a795ae | |||
| cd89883dbb | |||
| d5a729a2ba | |||
| b41533c278 | |||
| 04758587b4 | |||
| b6b0969879 | |||
| 18ef97fae6 | |||
| 333f052f86 | |||
| 7dd40eccf3 | |||
| db728840a0 | |||
| 8906436824 | |||
| e8dedb04a5 | |||
| d4b581c007 | |||
| a900beb3fd | |||
| 19a0f77c53 | |||
| 6dbd97ba14 | |||
| d2fbea8e39 | |||
| 86a49c6223 | |||
| e97f9b47d7 | |||
| ee3ed5f660 | |||
| 3446f3d1e0 | |||
| 69d03a7a42 | |||
| bab95cbefa | |||
| 5ef23fa49a | |||
| f4ff63485a | |||
| c20fbe8635 | |||
| 662cf65ff2 | |||
| 7ded517b20 | |||
| 4be31b0dad | |||
| dc439ba5be | |||
| 5ba8a05450 | |||
| 7ef19b318a | |||
| 2ac76ad852 | |||
| f4598f81c9 | |||
| c432dbb5bc | |||
| d0f0bb799e | |||
| a98dbfdf4f | |||
| a71909acd3 | |||
| ea5953a397 | |||
| 4ad9ccabe0 | |||
| 17640d44fa | |||
| 812d471573 | |||
| fa981d5a83 | |||
| 202f2c6cb0 | |||
| 55359bfa24 | |||
| 95fcfce9cd | |||
| 3120a2c43f | |||
| 7ba3a59dea | |||
| eb5f8fcfa1 | |||
| 5014227028 | |||
| 7a76de2e4c | |||
| de5692c1af | |||
| 555e4f0e65 | |||
| 723c670100 | |||
| 2f951dc272 | |||
| 0daabdc21c | |||
| 38a187e9fc | |||
| 5a613231e0 | |||
| 28a35e7260 | |||
| 461a5a780d | |||
| 207260821b | |||
| 466527884f | |||
| 9d03eb2643 | |||
| c801202642 | |||
| 95952fae75 | |||
| f62629b513 | |||
| f04087815c | |||
| 255b1c63d0 | |||
| 9b5b8ddc22 | |||
| d0a66f1701 | |||
| c176ac600b | |||
| cf0ab16533 | |||
| 03d0e2157e | |||
| cdd5137ebe | |||
| 0a924b2c29 | |||
| 43acecfc6e | |||
| 5e7e739589 | |||
| 0b968b6a98 | |||
| f14dfb6c17 | |||
| cb5ccd8166 | |||
| bfbcbb686d | |||
| 744300744c | |||
| 9bac099339 | |||
| 135c9fb64d | |||
| 4ed6fbbd74 | |||
| 4d3e9dc49b | |||
| 319360f8d0 | |||
| 3ef990b0bf | |||
| b8ae46b6df | |||
| 113aba0897 | |||
| a51672f3ee | |||
| f08b3eb006 | |||
| 66f65093fc | |||
| d78944e03b | |||
| 2fe31b876f | |||
| 9949ea364a | |||
| 77b7f7bfad | |||
| 8d4b458a22 | |||
| 2df8e77733 | |||
| c21011a17a | |||
| a11a691788 | |||
| 81659d4bf2 | |||
| aab20fd23e | |||
| 5fad4dd034 | |||
| 7bc19e8185 | |||
| 45d0928ff9 | |||
| 9b768273f4 | |||
| ef24b17a70 | |||
| dfbe5aaa16 | |||
| f499c9ada9 | |||
| c1a73aa62a | |||
| 601e787500 | |||
| d24bfabdc1 | |||
| 2c559d63f5 | |||
| b5a1554631 | |||
| 510e1c7296 | |||
| c6d8af5dc3 | |||
| adf884c2c4 | |||
| c7b321315c | |||
| 9f2eefcbb3 | |||
| fc2e39f41b | |||
| eae86d15ef | |||
| 361d80da17 | |||
| 2597402496 | |||
| c8bc6f9ffe | |||
| b0ef9238ff | |||
| b71e503a01 | |||
| e9f96593c3 | |||
| 36aa641cb9 | |||
| ddb46646fa | |||
| 96dc79cfe6 | |||
| e0e9f14a5e | |||
| b24e1142f8 | |||
| 0543b16de9 | |||
| 8d46c09f95 | |||
| 5724ca73b4 | |||
| 3e09bef613 | |||
| 627b1fe33f | |||
| 1aa270485c | |||
| ae09c19b69 | |||
| c5cf8eef1a | |||
| e76d4b3474 | |||
| 88a44ee065 | |||
| 51e02da277 | |||
| e9c3e42aa6 | |||
| 93a0063941 | |||
| 26a3cf79c5 | |||
| 26999afc22 | |||
| 81729e4b2a | |||
| 4bae5ee2fb | |||
| a786e6c8f5 | |||
| 3803f36aa5 | |||
| 55bc26bd09 | |||
| d84037a0dd | |||
| 1ce5fcafd9 | |||
| 281233f48b | |||
| 68d73e088d | |||
| b433191b35 | |||
| d75ad44315 | |||
| c3d3c3a6e9 | |||
| b9b8ccb8ae | |||
| 5a56a7c8af | |||
| d4efb63f3d | |||
| 2ec349e919 | |||
| 772770273a | |||
| fa5cbfc304 | |||
| 5276321ade | |||
| 6303602323 | |||
| 486fb0d10a | |||
| 74b8a08251 | |||
| 2a244bb8d4 | |||
| 84e73943f7 | |||
| ace09ca5a7 | |||
| a9ae34b149 | |||
| cff778fe6a | |||
| be69f9f8a3 | |||
| 5ca2078461 | |||
| 4461e7225f | |||
| 49d5d10d77 | |||
| 84374f03e9 | |||
| f8a44014f7 | |||
| 6befb64691 | |||
| 1ff2c21c61 | |||
| c79d4a24c4 | |||
| 3d7a5676d8 | |||
| aa362477e8 | |||
| 13b524e8a5 | |||
| d6eb6d3e3e | |||
| 91b8f1a457 | |||
| c8cdcfc99f | |||
| fe20e738cd | |||
| e23856bf10 | |||
| a7de7fb286 | |||
| a931d2a91f | |||
| c94c66b71e | |||
| cb89c30591 | |||
| 1012c0f654 | |||
| 9b5fb9ae8f | |||
| c4055271a8 | |||
| cd1df37ed3 | |||
| 3d8d4fd921 | |||
| 17b0c3e48d | |||
| f364257db9 | |||
| 6b0d9f8551 | |||
| f0fb420a8d | |||
| 8aa2695263 | |||
| b9af8ee6be | |||
| 7077289840 | |||
| bdd35fb02a | |||
| 47660c5679 | |||
| 28573f9676 | |||
| 375b7f6dd7 | |||
| 99ec2d5ce7 | |||
| f2afd654f8 | |||
| d42919285b | |||
| 33a1f135e0 | |||
| 214b836d13 | |||
| 408a07e8b9 | |||
| b247731062 | |||
| dcaa484929 | |||
| 35886633e5 | |||
| d04afc26e7 | |||
| bb5f1b703e | |||
| dfe2d27709 | |||
| 1dec4f0070 | |||
| 89b6513217 | |||
| 16a8caa8db | |||
| 1594d190eb | |||
| 3333f70a64 | |||
| 5bd803e6b4 | |||
| b5f5b096d4 | |||
| dce05140bf | |||
| 29d5ac94b2 | |||
| 2b80c6c1ad | |||
| 94a62b040b | |||
| 2bb9c50db9 | |||
| 6533ba4581 | |||
| 081909572e | |||
| aa84cb0079 | |||
| a66c3700b3 | |||
| 70476bd168 | |||
| a7929e142f | |||
| fd0d65b8ce | |||
| ef2a94c2c8 | |||
| b43daf2f08 | |||
| 280f628746 | |||
| 713774c03f | |||
| 0889c1531e | |||
| dbd5810a08 | |||
| c8722e9945 | |||
| 87780a2fc8 | |||
| ab03256db0 | |||
| e26640c80e | |||
| e6806453e1 | |||
| d0fb2583a5 | |||
| c4f8f318af | |||
| a6286bb67e | |||
| 90aea9708c | |||
| cb076123b3 | |||
| 70a9a66ae9 | |||
| 8521a47cfa | |||
| 106cc5238e | |||
| 2040eb22a2 | |||
| b6075a9765 | |||
| daacbcb89d | |||
| 6d622bbd14 | |||
| f355da4874 | |||
| 4b36de5200 | |||
| 88d37e99aa | |||
| 1608fc3fdc | |||
| 057fd18139 | |||
| b6371a0bdf | |||
| 03fe72e0b1 | |||
| 3bf4bddc10 | |||
| 92dcf19511 | |||
| b238443a9d | |||
| 021a39a964 | |||
| 72c494e9dc | |||
| 42cefd56eb | |||
| 944f163882 | |||
| 11a8a73723 | |||
| e34cf8f6a6 | |||
| 7f8143f06f | |||
| 472e513a9f | |||
| 1cbacab3a2 | |||
| 49bbb8588d | |||
| 23e0fe5791 | |||
| 6877dfb772 | |||
| f65b33f3fc | |||
| 3daddf2fe6 | |||
| efccf2729b | |||
| 3a1cd8f67f | |||
| 53c90429d3 | |||
| 7b5384a7d5 | |||
| 2b362d8eaf | |||
| ce0024a43c | |||
| 888696975d |
@@ -3122,3 +3122,67 @@
|
||||
[9.0.18]
|
||||
* ami & cloud images: fix setup
|
||||
|
||||
[9.1.0]
|
||||
* acme: ARI support . https://www.rfc-editor.org/rfc/rfc9773.txt
|
||||
* Update nodejs to 24.13.0
|
||||
* Update docker to 29.1.5
|
||||
* Update mongodb to 8.0.17
|
||||
* Update redis to 8.4.0
|
||||
* Add notification view. settings have moved to this new view.
|
||||
* updater: skip backup site check when user skips backup
|
||||
* community packages
|
||||
* source builds
|
||||
* backups: add integrity check UI
|
||||
* Fix fonts on chrome
|
||||
* applinks: fix acl UI
|
||||
* services: rename sftp to filemanager, graphite to metrics
|
||||
* app passwords: add expiry
|
||||
* DO Spaces: add missing ATL1, BLR1, SYD1 regions
|
||||
* filemanager: the terminal button automatically cds into the cwd
|
||||
* filemanager: add a tree view
|
||||
* passkey support
|
||||
* security: remove cors
|
||||
* support card/cal dav well-known endpoints
|
||||
* add backupCommand, restoreCommand, persistentDirs
|
||||
* Update Haraka to 3.1.3
|
||||
|
||||
[9.1.1]
|
||||
* cli: use web based browser login flow
|
||||
|
||||
[9.1.2]
|
||||
* apps: avoid flickering with filters
|
||||
* apps: move to error state if a volume is unavailable
|
||||
* apps: enable storage view in all error states
|
||||
* postgres: update pgvector to 0.8.2
|
||||
* appstore: add ai category
|
||||
* appstore: better tag/cateogry mapping
|
||||
* i18n: add Czech translations
|
||||
* Support and prefer Dockerfile.cloudron in local builds
|
||||
* integrity: show status in the info dialog
|
||||
* backup: show integrity column for dependsOn backups
|
||||
* integrity: show log link
|
||||
* syncer: fix bug with a file and dir having same prefix
|
||||
|
||||
[9.1.3]
|
||||
* Remove 'Dashboard' from dashboard page title
|
||||
* integrity: skip check of backups with no integrity info
|
||||
* backupintegrity: add percent progress
|
||||
* apps: fix acl display
|
||||
|
||||
[9.1.4]
|
||||
* services: lazy start services / on demand services
|
||||
* restore: fix restore of trusted ips and blocklist
|
||||
* dashboard: wait for dashboard reload when version has changed
|
||||
* graphite: fix aggregation of block/network read/write
|
||||
* Workaround chrome quirks on file drop handling
|
||||
* notifications: add empty text, progress bar and inifinite scroll
|
||||
* rsync: throttle log messages during download
|
||||
* backup logs: make them much terse and concise
|
||||
* oidc: implement Device Authorization Grant
|
||||
* operator: fix viewing of backup progress and logs
|
||||
* notification: automatic app update failure notification
|
||||
* backup sites: identify conflicting site locations
|
||||
* update: add policy to update apps and platform separately
|
||||
* passkey: fix issue where passkeys were lost on restart
|
||||
* passkey: implement passwordless login
|
||||
* oidcserver: fix jwks_rsaonly response
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
import constants from './src/constants.js';
|
||||
import fs from 'node:fs';
|
||||
import ldapServer from './src/ldapserver.js';
|
||||
import net from 'node:net';
|
||||
import oidcServer from './src/oidcserver.js';
|
||||
import paths from './src/paths.js';
|
||||
import proxyAuth from './src/proxyauth.js';
|
||||
import safe from 'safetydance';
|
||||
import server from './src/server.js';
|
||||
import directoryServer from './src/directoryserver.js';
|
||||
import logger from './src/logger.js';
|
||||
|
||||
const constants = require('./src/constants.js'),
|
||||
fs = require('node:fs'),
|
||||
ldapServer = require('./src/ldapserver.js'),
|
||||
net = require('node:net'),
|
||||
oidcServer = require('./src/oidcserver.js'),
|
||||
paths = require('./src/paths.js'),
|
||||
proxyAuth = require('./src/proxyauth.js'),
|
||||
safe = require('safetydance'),
|
||||
server = require('./src/server.js'),
|
||||
directoryServer = require('./src/directoryserver.js');
|
||||
const { log } = logger('box');
|
||||
|
||||
let logFd;
|
||||
|
||||
@@ -54,50 +55,43 @@ async function startServers() {
|
||||
if (conf.enabled) await directoryServer.start();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [error] = await safe(startServers());
|
||||
if (error) return exitSync({ error, code: 1, message: 'Error starting servers' });
|
||||
const [error] = await safe(startServers());
|
||||
if (error) exitSync({ error, code: 1, message: 'Error starting servers' });
|
||||
|
||||
// require this here so that logging handler is already setup
|
||||
const debug = require('debug')('box:box');
|
||||
process.on('SIGHUP', async function () {
|
||||
log('Received SIGHUP. Re-reading configs.');
|
||||
const conf = await directoryServer.getConfig();
|
||||
if (conf.enabled) await directoryServer.checkCertificate();
|
||||
});
|
||||
|
||||
process.on('SIGHUP', async function () {
|
||||
debug('Received SIGHUP. Re-reading configs.');
|
||||
const conf = await directoryServer.getConfig();
|
||||
if (conf.enabled) await directoryServer.checkCertificate();
|
||||
});
|
||||
process.on('SIGINT', async function () {
|
||||
log('Received SIGINT. Shutting down.');
|
||||
|
||||
process.on('SIGINT', async function () {
|
||||
debug('Received SIGINT. Shutting down.');
|
||||
await proxyAuth.stop();
|
||||
await server.stop();
|
||||
await directoryServer.stop();
|
||||
await ldapServer.stop();
|
||||
await oidcServer.stop();
|
||||
|
||||
await proxyAuth.stop();
|
||||
await server.stop();
|
||||
await directoryServer.stop();
|
||||
await ldapServer.stop();
|
||||
await oidcServer.stop();
|
||||
setTimeout(() => {
|
||||
log('Shutdown complete');
|
||||
process.exit();
|
||||
}, 2000); // need to wait for the task processes to die
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
debug('Shutdown complete');
|
||||
process.exit();
|
||||
}, 2000); // need to wait for the task processes to die
|
||||
});
|
||||
process.on('SIGTERM', async function () {
|
||||
log('Received SIGTERM. Shutting down.');
|
||||
|
||||
process.on('SIGTERM', async function () {
|
||||
debug('Received SIGTERM. Shutting down.');
|
||||
await proxyAuth.stop();
|
||||
await server.stop();
|
||||
await directoryServer.stop();
|
||||
await ldapServer.stop();
|
||||
await oidcServer.stop();
|
||||
|
||||
await proxyAuth.stop();
|
||||
await server.stop();
|
||||
await directoryServer.stop();
|
||||
await ldapServer.stop();
|
||||
await oidcServer.stop();
|
||||
setTimeout(() => {
|
||||
log('Shutdown complete');
|
||||
process.exit();
|
||||
}, 2000); // need to wait for the task processes to die
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
debug('Shutdown complete');
|
||||
process.exit();
|
||||
}, 2000); // need to wait for the task processes to die
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => exitSync({ error, code: 1, message: 'From uncaughtException handler.' }));
|
||||
}
|
||||
|
||||
main();
|
||||
process.on('uncaughtException', (uncaughtError) => exitSync({ error: uncaughtError, code: 1, message: 'From uncaughtException handler.' }));
|
||||
|
||||
+51
-11
@@ -2,19 +2,59 @@
|
||||
|
||||
<script>
|
||||
|
||||
const tmp = window.location.hash.slice(1).split('&');
|
||||
(async function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
|
||||
// FIXME: implicit flow (response_type=code token) results in access_token query param. this is not secure
|
||||
tmp.forEach(function (pair) {
|
||||
if (pair.indexOf('access_token=') === 0) localStorage.token = pair.split('=')[1];
|
||||
});
|
||||
if (!code) {
|
||||
console.error('No authorization code in callback URL');
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectTo = '/';
|
||||
if (localStorage.getItem('redirectToHash')) {
|
||||
redirectTo += localStorage.getItem('redirectToHash');
|
||||
localStorage.removeItem('redirectToHash');
|
||||
}
|
||||
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
|
||||
const clientId = sessionStorage.getItem('pkce_client_id') || 'cid-webadmin';
|
||||
const apiOrigin = sessionStorage.getItem('pkce_api_origin') || '';
|
||||
|
||||
window.location.replace(redirectTo); // this removes us from history
|
||||
sessionStorage.removeItem('pkce_code_verifier');
|
||||
sessionStorage.removeItem('pkce_client_id');
|
||||
sessionStorage.removeItem('pkce_api_origin');
|
||||
|
||||
try {
|
||||
const response = await fetch(apiOrigin + '/openid/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
client_id: clientId,
|
||||
redirect_uri: window.location.origin + '/authcallback.html',
|
||||
code_verifier: codeVerifier
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.access_token) {
|
||||
console.error('Token exchange failed', data);
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.token = data.access_token;
|
||||
} catch (e) {
|
||||
console.error('Token exchange error', e);
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectTo = '/';
|
||||
if (localStorage.getItem('redirectToHash')) {
|
||||
redirectTo += localStorage.getItem('redirectToHash');
|
||||
localStorage.removeItem('redirectToHash');
|
||||
}
|
||||
|
||||
window.location.replace(redirectTo);
|
||||
})();
|
||||
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
set -eu
|
||||
|
||||
# Check if the API origin is set, if not prompt the user to enter it
|
||||
if [[ -z "${DASHBOARD_DEVELOPMENT_ORIGIN:-}" ]]; then
|
||||
read -p "Enter the API origin (e.g. http://localhost:3000): " DASHBOARD_DEVELOPMENT_ORIGIN
|
||||
fi
|
||||
|
||||
echo "=> Set API origin"
|
||||
export VITE_API_ORIGIN="${DASHBOARD_DEVELOPMENT_ORIGIN}"
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export default [
|
||||
"prefer-const": "error",
|
||||
"vue/no-reserved-component-names": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-undef-components": "error",
|
||||
'vue/no-root-v-if': "error",
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title><%= name %> Dashboard</title>
|
||||
<title><%= name %></title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" style="overflow: hidden; height: 100%;"></div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Confirm Device</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin-top: 25px; margin-bottom: 25px; }
|
||||
h1, h1+p { font-weight: 100; text-align: center; }
|
||||
.container { padding: 0 40px 10px; width: 274px; background-color: #f7f7f7; margin: 0 auto 10px; border-radius: 2px; box-shadow: 0 2px 2px rgba(0,0,0,.3); overflow: hidden; }
|
||||
h1 { font-size: 2.3em; }
|
||||
code { font-size: 2em; }
|
||||
button[autofocus] { width: 100%; display: block; margin-bottom: 10px; font-size: 14px; font-weight: 700; height: 36px; padding: 0 8px; border: 0; color: #fff; background-color: #4d90fe; cursor: pointer; }
|
||||
button[autofocus]:hover { background-color: #357ae8; }
|
||||
button[name=abort] { background: none; border: none; padding: 0; font: inherit; cursor: pointer; color: #666; opacity: .6; }
|
||||
.help { width: 100%; font-size: 12px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Confirm Device</h1>
|
||||
<p>
|
||||
<strong><%= clientName %></strong>
|
||||
<br/><br/>
|
||||
The following code should be displayed on your device<br/><br/>
|
||||
<code><%= userCode %></code>
|
||||
<br/><br/>
|
||||
<small>If you did not initiate this action or the code does not match, please close this window or click abort.</small>
|
||||
</p>
|
||||
<%- form %>
|
||||
<button autofocus type="submit" form="op.deviceConfirmForm">Continue</button>
|
||||
<div class="help">
|
||||
<button type="submit" form="op.deviceConfirmForm" value="yes" name="abort">[ Abort ]</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Sign-in</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin-top: 25px; margin-bottom: 25px; }
|
||||
h1, h1+p { font-weight: 100; text-align: center; }
|
||||
.container { padding: 0 40px 10px; width: 274px; background-color: #f7f7f7; margin: 0 auto 10px; border-radius: 2px; box-shadow: 0 2px 2px rgba(0,0,0,.3); overflow: hidden; }
|
||||
h1 { font-size: 2.3em; }
|
||||
p.red { color: #d50000; }
|
||||
input[type=text] { height: 44px; font-size: 16px; width: 100%; margin-bottom: 10px; background: #fff; border: 1px solid #d9d9d9; border-top: 1px solid silver; padding: 0 8px; box-sizing: border-box; text-transform: uppercase; text-align: center; }
|
||||
input[type=text]::placeholder { text-transform: none; }
|
||||
[type=submit] { width: 100%; display: block; margin-bottom: 10px; text-align: center; font-size: 14px; font-weight: 700; height: 36px; padding: 0 8px; border: 0; color: #fff; background-color: #4d90fe; cursor: pointer; }
|
||||
[type=submit]:hover { background-color: #357ae8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Sign-in</h1>
|
||||
<%- message %>
|
||||
<%- form %>
|
||||
<button type="submit" form="op.deviceInputForm">Continue</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Success</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin-top: 25px; margin-bottom: 25px; }
|
||||
h1, h1+p { font-weight: 100; text-align: center; }
|
||||
.container { padding: 0 40px 10px; width: 274px; background-color: #f7f7f7; margin: 0 auto 10px; border-radius: 2px; box-shadow: 0 2px 2px rgba(0,0,0,.3); overflow: hidden; }
|
||||
h1 { font-size: 2.3em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Success</h1>
|
||||
<p>Your device has been authorized. You can close this window.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,6 +9,8 @@
|
||||
name: name,
|
||||
note: note,
|
||||
submitUrl: submitUrl,
|
||||
passkeyAuthOptionsUrl: passkeyAuthOptionsUrl,
|
||||
passkeyLoginUrl: passkeyLoginUrl,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
|
||||
Generated
+1606
-2637
File diff suppressed because it is too large
Load Diff
+16
-15
@@ -7,26 +7,27 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@cloudron/pankow": "^3.6.4",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@cloudron/pankow": "^4.1.5",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"anser": "^2.3.3",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@xterm/addon-attach": "^0.12.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"anser": "^2.3.5",
|
||||
"async": "^3.2.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"marked": "^17.0.1",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"marked": "^17.0.4",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"vite": "^7.2.7",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-router": "^4.6.3"
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-singlefile": "^2.3.2",
|
||||
"vue": "^3.5.30",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -296,8 +296,6 @@
|
||||
"password": "Adgangskode til bekræftelse"
|
||||
},
|
||||
"changePasswordAction": "Ændre adgangskode",
|
||||
"disable2FAAction": "Deaktivere 2FA",
|
||||
"enable2FAAction": "Aktiver 2FA",
|
||||
"passwordResetNotification": {
|
||||
"body": "E-mail sendt til {{ email }}"
|
||||
}
|
||||
@@ -555,11 +553,8 @@
|
||||
"updateScheduleDialog": {
|
||||
"selectOne": "Vælg mindst én dag og ét tidspunkt",
|
||||
"description": "Vælg de dage og timer, hvor Cloudron vil anvende automatiske platforms- og appopdateringer. Pas på, at denne tidsplan ikke overlapper med <a href=\"/#/backups\">backup-tidsplanen</a>.",
|
||||
"title": "Konfigurer tidsplan for automatisk opdatering",
|
||||
"disableCheckbox": "Deaktivere automatiske opdateringer",
|
||||
"enableCheckbox": "Aktivere automatiske opdateringer",
|
||||
"days": "Dage",
|
||||
"hours": "Timer"
|
||||
"enableCheckbox": "Aktivere automatiske opdateringer"
|
||||
},
|
||||
"updateDialog": {
|
||||
"unstableWarning": "Denne opdatering er en præudgave og betragtes ikke som stabil endnu. Opdatering sker på egen risiko.",
|
||||
@@ -1067,11 +1062,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"description": "Apps kan stoppes for at spare på serverressourcerne i stedet for at blive afinstalleret. Fremtidige app-backups vil ikke omfatte app-ændringer mellem nu og den seneste app-backup. Derfor anbefales det at udløse en sikkerhedskopi, før appen stoppes.",
|
||||
"startAction": "Start app",
|
||||
"stopAction": "Stop App"
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "Afinstaller",
|
||||
"description": "Dette vil afinstallere appen med det samme og fjerne alle dens data. Der vil ikke være adgang til webstedet.",
|
||||
|
||||
@@ -42,10 +42,12 @@
|
||||
"next": "Weiter",
|
||||
"configure": "Konfigurieren",
|
||||
"restart": "Neu starten",
|
||||
"reset": "Zurücksetzen"
|
||||
"reset": "Zurücksetzen",
|
||||
"loadMore": "Mehr laden"
|
||||
},
|
||||
"table": {
|
||||
"version": "Version"
|
||||
"version": "Version",
|
||||
"created": "Erstellt"
|
||||
},
|
||||
"actions": "Aktionen",
|
||||
"rebootDialog": {
|
||||
@@ -66,6 +68,9 @@
|
||||
"loadingPlaceholder": "Laden",
|
||||
"platform": {
|
||||
"startupFailed": "Plattform-Start fehlgeschlagen"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapseAction": "Seitenleiste einklappen"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -129,7 +134,6 @@
|
||||
"updateAvailableAction": "Aktualisierung verfügbar",
|
||||
"description": "Plattform und App-Aktualisierungen werden automatisch, basierend auf dem Zeitplan in der <a href=\"/#/system-settings\">Systemzeitzone</a> ausgeführt.",
|
||||
"disabled": "Deaktiviert",
|
||||
"schedule": "Aktualisierungszeitplan",
|
||||
"onLatest": "neueste"
|
||||
},
|
||||
"appstoreAccount": {
|
||||
@@ -149,13 +153,10 @@
|
||||
}
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"hours": "Stunden",
|
||||
"disableCheckbox": "Automatische Aktualisierung deaktivieren",
|
||||
"enableCheckbox": "Automatische Aktualisierung aktivieren",
|
||||
"selectOne": "Mindestens einen Tag und eine Uhrzeit wählen",
|
||||
"days": "Tage",
|
||||
"description": "Tage und Stunden auswählen, an denen Cloudron das System und die Anwendungen aktualisieren soll. Der Zeitplan soll sich nicht mit dem Zeitplan für Datensicherungen überschneiden.",
|
||||
"title": "Automatische Aktualisierung konfigurieren"
|
||||
"description": "Tage und Stunden auswählen, an denen Cloudron das System und die Anwendungen aktualisieren soll. Der Zeitplan soll sich nicht mit dem Zeitplan für Datensicherungen überschneiden."
|
||||
},
|
||||
"timezone": {
|
||||
"description": "Dient dazu, Datensicherungen und Updates zu planen. UI-Zeitstempel folgen immer der Zeitzone des Browsers.",
|
||||
@@ -365,8 +366,6 @@
|
||||
"description": "App-Passwörter sind eine Sicherheitsmaßnahme zum Schutz des Cloudron-User-Kontos. Sobald eingerichtet, kann die Anmeldung (zusätzlich) mit dem Usernamen und dem hier angezeigtem Passwort erfolgen. Hinweis: sinnvoll bei nicht vertrauenswürdigen mobilen Anwendungen oder Desktop-Clients.",
|
||||
"title": "App-Passwörter"
|
||||
},
|
||||
"enable2FAAction": "2FA aktivieren",
|
||||
"disable2FAAction": "2FA deaktivieren",
|
||||
"changePasswordAction": "Passwort ändern",
|
||||
"createApiToken": {
|
||||
"copyNow": "API-Token kopieren. Hinweis: keine erneute Anzeige des API-Tokens.",
|
||||
@@ -401,7 +400,7 @@
|
||||
"description": "Persönlichen Zugriffstoken zur Authentifizierung gegenüber der <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a> verwenden.",
|
||||
"name": "Name",
|
||||
"title": "API-Tokens",
|
||||
"lastUsed": "Zuletzt Verwendet",
|
||||
"lastUsed": "Zuletzt verwendet",
|
||||
"neverUsed": "nie",
|
||||
"scope": "Bereich",
|
||||
"readonly": "Schreibgeschützt",
|
||||
@@ -630,7 +629,12 @@
|
||||
"settingsDialog": {
|
||||
"description": "Eine E-Mail wird für die ausgewählten Ereignisse an Ihre primäre E-Mail-Adresse gesendet."
|
||||
},
|
||||
"allCaughtUp": "Alles erledigt"
|
||||
"allCaughtUp": "Alles erledigt",
|
||||
"title": "Benachrichtigungen",
|
||||
"showAll": "Alle",
|
||||
"showUnread": "Ungelesen",
|
||||
"markUnread": "Als ungelesen markieren",
|
||||
"markRead": "Als gelesen markieren"
|
||||
},
|
||||
"system": {
|
||||
"diskUsage": {
|
||||
@@ -741,12 +745,12 @@
|
||||
"enable": "Automatische Datensicherung aktivieren"
|
||||
},
|
||||
"backupDetails": {
|
||||
"version": "Version",
|
||||
"date": "Datum",
|
||||
"id": "Id",
|
||||
"version": "Paketversion",
|
||||
"date": "Erstellt",
|
||||
"id": "Datensicherungs Id",
|
||||
"title": "Backup-Details",
|
||||
"size": "Größe",
|
||||
"duration": "Dauer"
|
||||
"duration": "Datensicherungsdauer"
|
||||
},
|
||||
"listing": {
|
||||
"backupNow": "Backup jetzt erstellen",
|
||||
@@ -758,7 +762,8 @@
|
||||
"contents": "Inhalt",
|
||||
"noBackups": "Keine Datensicherungen",
|
||||
"title": "Datensicherungen",
|
||||
"tooltipPreservedBackup": "Dieses Backup bleibt erhalten"
|
||||
"tooltipPreservedBackup": "Dieses Backup bleibt erhalten",
|
||||
"description": "System-Datensicherungen enthalten die Cloudron-Konfiguration und Metadaten der App-Installation. Sie können dazu verwendet werden, die gesamte Cloudron-Installation auf einen anderen Server zu <a href=\"{{restoreLink}}\" target=\"_blank\">wiederherzustellen</a> oder zu <a href=\"{{migrateLink}}\" target=\"_blank\">migrieren</a>."
|
||||
},
|
||||
"schedule": {
|
||||
"retentionPolicy": "Aufbewahrungsrichtlinie",
|
||||
@@ -1181,7 +1186,7 @@
|
||||
"title": "Ungültiger oder abgelaufener Einladungslink"
|
||||
},
|
||||
"success": {
|
||||
"title": "Ihr Konto ist bereit",
|
||||
"title": "Konto ist bereit",
|
||||
"openDashboardAction": "Dashboard öffnen"
|
||||
},
|
||||
"fullName": "Vollständiger Name",
|
||||
@@ -1193,8 +1198,9 @@
|
||||
"description": "Konto einrichten",
|
||||
"noUsername": {
|
||||
"title": "Das Konto kann nicht eingerichtet werden.",
|
||||
"description": "Ein Konto kann nicht ohne einen Benutzernamen eingerichtet werden."
|
||||
}
|
||||
"description": "Ein Konto kann nicht ohne einen Benutzernamen eingerichtet werden. Kontaktiere den Administrator."
|
||||
},
|
||||
"welcome": "Willkommen"
|
||||
},
|
||||
"app": {
|
||||
"accessControl": {
|
||||
@@ -1209,10 +1215,10 @@
|
||||
"visibleForSelected": "Nur für die folgenden User und Gruppen sichtbar",
|
||||
"descriptionSftp": "Steuert auch den SFTP-Zugriff.",
|
||||
"visibleForAllUsers": "Sichtbar für alle User auf dieser Cloudron-Instanz",
|
||||
"description": "Konfiguriere, wer sich anmelden darf und die App verwenden kann."
|
||||
"description": "Konfiguriere, wer sich anmelden darf und die App verwenden kann"
|
||||
},
|
||||
"operators": {
|
||||
"description": "Die Betreiber können diese Anwendung konfigurieren und pflegen.",
|
||||
"description": "Wer kann Anwendung konfigurieren und pflegen",
|
||||
"title": "Administratoren"
|
||||
},
|
||||
"dashboardVisibility": {
|
||||
@@ -1232,19 +1238,37 @@
|
||||
"description": "Maximaler Arbeitsspeicher der dieser App zur Verfügung steht"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Geräte"
|
||||
"label": "Geräte",
|
||||
"description": "Durch Kommas getrennte Liste der an die App angeschlossenen Geräte"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": {
|
||||
"saveAction": "Speichern",
|
||||
"description": "Das Setzen dieser Option überschreibt alle CSP-Header, die von der Anwendung selbst gesendet werden.",
|
||||
"title": "Content-Security-Policy"
|
||||
"description": "Überschreibe alle CSP-Header, die von der App definiert sind.",
|
||||
"title": "Content-Security-Policy",
|
||||
"insertCommonCsp": "Gängige CSP einfügen",
|
||||
"commonPattern": {
|
||||
"allowEmbedding": "Einbetten zulassen",
|
||||
"sameOriginEmbedding": "Einbetten zulassen (nur Unterdomänen)",
|
||||
"allowCdnAssets": "CDN-Ressourcen zulassen",
|
||||
"reportOnly": "CSP-Verstöße melden",
|
||||
"strictBaseline": "Strikte Baseline"
|
||||
}
|
||||
},
|
||||
"robots": {
|
||||
"title": "robots.txt"
|
||||
"title": "robots.txt",
|
||||
"description": "Standardmäßig können Bots diese App indexieren",
|
||||
"commonPattern": {
|
||||
"allowAll": "Alle zulassen (Standard)",
|
||||
"disallowAll": "Alle verweigern",
|
||||
"disallowCommonBots": "Gängige Bots blockieren",
|
||||
"disallowAdminPaths": "Admin-Pfade sperren",
|
||||
"disallowApiPaths": "API-Pfade sperren"
|
||||
},
|
||||
"insertCommonRobotsTxt": "Gängige robots.txt einfügen"
|
||||
},
|
||||
"hstsPreload": "Aktivieren Sie den HSTS-Preload für diese Website und alle Subdomains"
|
||||
"hstsPreload": "HSTS-Preload aktivieren (einschließlich Unterdomänen)"
|
||||
},
|
||||
"email": {
|
||||
"from": {
|
||||
@@ -1252,17 +1276,20 @@
|
||||
"mailboxPlaceholder": "Postfachname",
|
||||
"saveAction": "Speichern",
|
||||
"disableDescription": "Die E-Mail Einstellungen werden nicht automatisch vorgenommen, dies muss in der App selbst gemacht werden.",
|
||||
"enable": "Verwende Cloudron um E-Mails zu versenden",
|
||||
"enableDescription": "Diese App verwendet die <a href=\"{{ domainConfigLink }}\">ausgehende E-Mail Konfiguration</a> der Domäne {{ domain }}.",
|
||||
"enable": "Verwende Cloudron, um E-Mails zu versenden",
|
||||
"enableDescription": "Konfigurieren Sie die App so, dass E-Mail über die untenstehende Adresse gesendet wird und <a href=\"{{ domainConfigLink }}\">ausgehende E-Mail</a> Einstellungen.",
|
||||
"disable": "E-Mail Konfiguration nicht automatisch vornehmen",
|
||||
"displayName": "Absendername"
|
||||
},
|
||||
"inbox": {
|
||||
"title": "Eingehende E-Mail",
|
||||
"enable": "Benutze Cloudron Mail um E-Mails zu empfangen",
|
||||
"enableDescription": "Die App ist so konfiguriert, dass sie E-Mails über die unten stehende Adresse empfängt. Wählen Sie diese Option, wenn die E-Mail für {{ domain }} auf diesem Server gehostet wird.",
|
||||
"enableDescription": "Konfigurieren Sie die App so, dass sie E-Mail über die untenstehende Adresse empfängt. Wählen Sie diese Option, wenn die E-Mail von {{ domain }} auf diesem Server gehostet wird.",
|
||||
"disableDescription": "Die Einstellungen für den Posteingang in der App sind nicht betroffen. Sie können sie innerhalb der App konfigurieren. Wählen Sie dies, wenn die E-Mail der Domain nicht auf Cloudron gehostet wird.",
|
||||
"disable": "Posteingang nicht konfigurieren"
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Ausgehende E-Mails"
|
||||
}
|
||||
},
|
||||
"repair": {
|
||||
@@ -1285,13 +1312,8 @@
|
||||
},
|
||||
"repairTabTitle": "Reparatur",
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"startAction": "Starten",
|
||||
"stopAction": "Stoppen",
|
||||
"description": "Anwendungen können angehalten werden, um Server-Ressourcen zu schonen, anstatt sie zu deinstallieren. Zukünftige Anwendungs-Backups werden keine Änderungen von Anwendungen zwischen jetzt und dem letzten Anwendungs-Backup enthalten. Aus diesem Grund wird empfohlen, vor dem Stoppen der Anwendung ein Backup auszulösen."
|
||||
},
|
||||
"uninstall": {
|
||||
"description": "Dies wird die Anwendung deinstallieren und alle zugehörigen Daten löschen. Datensicherungen werden basierend der Aufbewahrungseinstellungen bereinigt.",
|
||||
"description": "Anwendung deinstallieren und alle zugehörigen Daten löschen. Datensicherungen werden basierend der Aufbewahrungseinstellungen bereinigt.",
|
||||
"title": "Deinstallieren",
|
||||
"uninstallAction": "Deinstallieren"
|
||||
}
|
||||
@@ -1310,11 +1332,11 @@
|
||||
"appId": "ID der Anwendung",
|
||||
"lastUpdated": "Letzte Aktualisierung",
|
||||
"customAppUpdateInfo": "Aktualiserung steht für benutzerdefinierte Anwendungen nicht zur Verfügung",
|
||||
"packageVersion": "Paket-Version",
|
||||
"packageVersion": "Paket",
|
||||
"installedAt": "Installationszeitpunkt"
|
||||
},
|
||||
"auto": {
|
||||
"description": "App-Updates werden regelmäßig gemäß dem Aktualisierungszeitplan angewendet.",
|
||||
"description": "App-Updates werden regelmäßig gemäß dem <a href=\"/#/system-update\">Aktualisierungszeitplan</a> angewendet",
|
||||
"title": "Automatische Updates"
|
||||
},
|
||||
"updates": {
|
||||
@@ -1325,7 +1347,7 @@
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"downloadConfigTooltip": "Konfiguration herunterladen",
|
||||
"description": "Backups erstellen komplette Abbilder der Anwendung. Ein Anwendungsbackup kann zum Wiederherstellen oder Klonen dieser Anwendung verwendet werden.",
|
||||
"description": "Vollständige Datensicherung der App erstellen",
|
||||
"importAction": "Backup importieren",
|
||||
"cloneTooltip": "Duplizieren",
|
||||
"restoreTooltip": "Wiederherstellen",
|
||||
@@ -1335,11 +1357,11 @@
|
||||
},
|
||||
"auto": {
|
||||
"title": "Automatische Backups",
|
||||
"description": "Die App wird periodisch gemäß dem Datensicherungszeitplan gesichert."
|
||||
"description": "Regelmäßig eine Datensicherung der App auf die konfigurierten <a href=\"/#/backup-sites\">Datensicherungsstandorte</a> erstellen"
|
||||
},
|
||||
"import": {
|
||||
"title": "Von einem externen Backup importieren",
|
||||
"description": "Dies hier verwenden, um eine Anwendung von einer anderen Cloudron-Instanz zu migrieren. Die zu migrierende Anwendung muss die gleiche Paket-Version und Zugriffsrechte aufweisen wie diese hier."
|
||||
"title": "Importieren",
|
||||
"description": "App aus einer externen Datensicherung importieren"
|
||||
}
|
||||
},
|
||||
"appInfo": {
|
||||
@@ -1352,14 +1374,14 @@
|
||||
"storage": {
|
||||
"appdata": {
|
||||
"title": "Datenverzeichnis",
|
||||
"description": "Wenn dem Server der Speicherplatz ausgeht, kann durch Hinzufügen einer <a href=\"/#/volumes\">externen Festplatte</a>, die Daten der Anwendung dorthin verschoben werden.",
|
||||
"description": "Verschiebe die App-Daten auf einen <a href=\"/#/volumes\">Datenträger</a>. Alle hier befindlichen Daten sind in der Datensicherung der App enthalten.",
|
||||
"moveAction": "Daten verschieben",
|
||||
"mountTypeWarning": "Das Zieldateisystem muss Dateiberechtigungen und Eigentümerschaft unterstützen, damit die Verschiebung funktioniert"
|
||||
},
|
||||
"mounts": {
|
||||
"title": "Datenträger Mounts",
|
||||
"volume": "Datenträger",
|
||||
"noMounts": "Es sind keine Datenträger gemounted.",
|
||||
"noMounts": "Kein Datenträger eingehängt",
|
||||
"addMountAction": "Einen Datenträger mount hinzufügen",
|
||||
"saveAction": "Speichern",
|
||||
"permissions": {
|
||||
@@ -1370,15 +1392,15 @@
|
||||
}
|
||||
},
|
||||
"uninstallDialog": {
|
||||
"title": "{{ app }} deinstallieren",
|
||||
"description": "Dies wird {{ app }} sofort deinstallieren und alle Daten löschen.",
|
||||
"title": "Anwendung deinstallieren",
|
||||
"description": "Anwendung {{ app }} deinstallieren und alle Daten löschen.",
|
||||
"uninstallAction": "Deinstallieren"
|
||||
},
|
||||
"restoreDialog": {
|
||||
"warning": "Alle Daten, die zwischen jetzt und der letzten bekannten Sicherung erzeugt wurden, gehen unwiderruflich verloren. Es wird empfohlen, ein Backup der aktuellen Daten zu erstellen, bevor eine Wiederherstellung versucht wird.",
|
||||
"warning": "Alle Daten, die seit der letzten Datensicherung erstellt wurden, gehen dauerhaft verloren. Es wird empfohlen, vor dem Import eine neue Datensicherung zu erstellen.",
|
||||
"restoreAction": "Wiederherstellen",
|
||||
"title": "{{ app }} wiederherstellen",
|
||||
"description": "Hierdurch wird diese Anwendung mit den Daten vom {{ creationTime }} wiederhergestellt.",
|
||||
"title": "App wiederherstellen",
|
||||
"description": "Wiederherstellen von \"{{ fqdn }}\" aus der Datensicherung, die am {{ creationTime }} erstellt wurde?",
|
||||
"cloneAction": "Klonen",
|
||||
"cloneActionOverwrite": "DNS klonen und DNS überschreiben"
|
||||
},
|
||||
@@ -1405,8 +1427,8 @@
|
||||
"updateAction": "Aktualisieren"
|
||||
},
|
||||
"cloneDialog": {
|
||||
"title": "{{ app }} klonen",
|
||||
"description": "Backup vom <b>{{ creationTime }}</b> und der Version <b>v{{ packageVersion }}</b> verwenden",
|
||||
"title": "App klonen",
|
||||
"description": "Klonen mit der Datensicherung vom <b>{{ creationTime }}</b> (Version <b>{{ packageVersion }}</b>).",
|
||||
"location": "Standort"
|
||||
},
|
||||
"graphs": {
|
||||
@@ -1427,8 +1449,10 @@
|
||||
"title": "Backup importieren",
|
||||
"uploadAction": "Datensicherungskonfiguration hochladen",
|
||||
"importAction": "Importieren",
|
||||
"remotePath": "Backup-Pfad",
|
||||
"provideBackupInfo": "Geben Sie die Datensicherungsinformationen an, von denen wiederhergestellt werden soll, oder"
|
||||
"remotePath": "Datensicherungs-Pfad",
|
||||
"provideBackupInfo": "Geben Sie die Datensicherungsinformationen an, von denen wiederhergestellt werden soll, oder",
|
||||
"warning": "Alle Daten, die seit der letzten Datensicherung erstellt wurden, gehen dauerhaft verloren. Es wird empfohlen, vor dem Import eine neue Datensicherung zu erstellen.",
|
||||
"versionMustMatchInfo": "Die Datensicherung muss mit derselben Paketversion und denselben Zugriffssteuerungseinstellungen wie diese App erstellt worden sein."
|
||||
},
|
||||
"terminalActionTooltip": "Terminal",
|
||||
"filemanagerActionTooltip": "Dateimanager",
|
||||
@@ -1460,8 +1484,8 @@
|
||||
},
|
||||
"title": "Crontab",
|
||||
"saveAction": "Speichern",
|
||||
"addCommonPattern": "Häufige Muster hinzufügen",
|
||||
"description": "Benutzerdefinierte app-spezifische Cronjobs können hier hinzugefügt werden. Beachten Sie, dass Cronjobs, die für das Funktionieren der App erforderlich sind, bereits in das App-Paket integriert sind und hier nicht konfiguriert werden müssen."
|
||||
"addCommonPattern": "Gängiges Muster einfügen",
|
||||
"description": "Für den Betrieb der App erforderliche Cron-Jobs sind bereits im App-Paket integriert. Fügen Sie hier nur zusätzliche Jobs hinzu, die speziell zu Ihrem Setup passen."
|
||||
},
|
||||
"sftpInfoAction": "SFTP Zugang",
|
||||
"cronTabTitle": "Cron",
|
||||
@@ -1479,7 +1503,7 @@
|
||||
},
|
||||
"redis": {
|
||||
"title": "Redis Konfiguration",
|
||||
"info": "Wenn aktiviert, verwendet die App den integrierten Redis-Dienst. Wenn deaktiviert, bleiben die Redis-Einstellungen der App unberührt."
|
||||
"info": "Integrierten Redis-Dienst verwenden. Wenn er deaktiviert ist, bleiben die App-Redis-Einstellungen unverändert."
|
||||
},
|
||||
"infoTabTitle": "Info",
|
||||
"info": {
|
||||
@@ -1489,19 +1513,19 @@
|
||||
},
|
||||
"turn": {
|
||||
"title": "TURN Einstellungen",
|
||||
"info": "Aktivieren Sie diese Option, um die App so zu konfigurieren, dass der integrierte TURN-Server verwendet wird. Wenn deaktiviert, bleiben die TURN-Einstellungen der App unverändert."
|
||||
"info": "Verwenden Sie den eingebauten TURN-Server. Wenn deaktiviert, bleiben die TURN-Einstellungen der App unverändert."
|
||||
},
|
||||
"servicesTabTitle": "Dienste",
|
||||
"archive": {
|
||||
"title": "Archiv",
|
||||
"action": "Archiv",
|
||||
"noBackup": "Diese App hat keine Datensicherung. Archivierung benötigt eine aktuelle Datensicherung.",
|
||||
"description": "Die letzte Datensicherung der App wird dem <a href=\"/#/backups\">Archiv</a> hinzugefügt. Die App wird deinstalliert, aber kann im Datensicherungsbereich wiederhergestellt werden. Die anderen Datensicherungen werden basierend der Aufbewahrungseinstellungen bereinigt.",
|
||||
"description": "Die letzte Datensicherung der App wird dem <a href=\"/#/app-archive\">Archiv</a> hinzugefügt und die App deinstalliert.",
|
||||
"latestBackupInfo": "Die letzte Datensicherung wurde am {{ date }} erstellt."
|
||||
},
|
||||
"archiveDialog": {
|
||||
"description": "Dies deinstalliert die App und legt die letzte Datensicherung, erstellt am {{ date }} ins Archiv.",
|
||||
"title": "Archiviere {{ app }}"
|
||||
"description": "App deinstallieren und letzte Datensicherung vom {{ date }} ins Archiv legen?",
|
||||
"title": "App archivieren"
|
||||
},
|
||||
"configureTooltip": "Konfigurieren",
|
||||
"updateAvailableTooltip": "Aktualisierung verfügbar",
|
||||
@@ -1516,13 +1540,15 @@
|
||||
"clear": "Anzeige löschen"
|
||||
},
|
||||
"volumes": {
|
||||
"description": "Datenträger sind Verzeichnisse auf dem Server, die von Anwendungen gemeinsam genutzt werden können.",
|
||||
"description": "Datenträger sind Verzeichnisse auf dem Server, die von Apps gemeinsam genutzt werden können.",
|
||||
"removeVolumeDialog": {
|
||||
"removeAction": "Entfernen"
|
||||
"removeAction": "Entfernen",
|
||||
"title": "Datenträger entfernen",
|
||||
"description": "Datenträger \"{{ volumeName }}\" entfernen?"
|
||||
},
|
||||
"addVolumeDialog": {
|
||||
"title": "Datenträger hinzufügen",
|
||||
"server": "Server IP oder Hostname",
|
||||
"server": "Server IP / Hostname",
|
||||
"remoteDirectory": "Remote-Verzeichnis",
|
||||
"username": "Username",
|
||||
"password": "Passwort",
|
||||
@@ -1538,7 +1564,7 @@
|
||||
"localDirectory": "Lokales Verzeichnis",
|
||||
"remountActionTooltip": "Neu einhängen",
|
||||
"editVolumeDialog": {
|
||||
"title": "Datenträger {{ name }} konfigurieren"
|
||||
"title": "Datenträger konfigurieren"
|
||||
},
|
||||
"emptyPlaceholder": "Keine Datenträger"
|
||||
},
|
||||
@@ -1551,7 +1577,7 @@
|
||||
},
|
||||
"storage": {
|
||||
"mounts": {
|
||||
"description": "Eingehängte Datenträger können unter <code>/media/(Datenträgername)</code> zugegriffen werden. Eingehängte Daten werden nicht in der Datensicherung der App erfasst."
|
||||
"description": "Eingehängte Datenträger können unter \"/media/(Datenträgername)\" zugegriffen werden. Eingehängte Daten werden nicht in der Datensicherung der App erfasst."
|
||||
}
|
||||
},
|
||||
"oidc": {
|
||||
@@ -1560,19 +1586,20 @@
|
||||
"createAction": "Hinzufügen"
|
||||
},
|
||||
"client": {
|
||||
"name": "Name",
|
||||
"name": "Client Name",
|
||||
"id": "Client ID",
|
||||
"signingAlgorithm": "Signatur Algorithmus",
|
||||
"loginRedirectUri": "Login Callback URLs (mit Komma getrennt)",
|
||||
"secret": "Client Geheimnis"
|
||||
"loginRedirectUri": "Login Callback URLs",
|
||||
"secret": "Client Geheimnis",
|
||||
"loginRedirectUriPlaceholder": "Durch Kommas getrennte URLs"
|
||||
},
|
||||
"description": "OpenID kann von externen Anwendungen für Single Sign-On verwendet werden.",
|
||||
"editClientDialog": {
|
||||
"title": "Client {{ client }} bearbeiten"
|
||||
"title": "Client bearbeiten"
|
||||
},
|
||||
"deleteClientDialog": {
|
||||
"title": "Wirklich Client {{ client }} löschen?",
|
||||
"description": "Wenn dies gelöscht wird, werden alle Tokens dieses OpenID Clients, ungültig gemacht. Damit werden alle externen OpenID Apps, die diese Clientendetails nutzen, getrennt."
|
||||
"title": "Client löschen",
|
||||
"description": "Nach der Löschung werden alle von diesem Client ausgestellten Zugriffstoken ungültig. Apps, die ihn verwenden, können sich nicht mehr authentifizieren.<br/><br/>Client '{{ clientName }}' löschen?"
|
||||
},
|
||||
"env": {
|
||||
"discoveryUrl": "Discovery URL"
|
||||
@@ -1580,6 +1607,10 @@
|
||||
"clients": {
|
||||
"title": "OpenID-Clients",
|
||||
"empty": "Keine OpenID-Clients"
|
||||
},
|
||||
"clientCredentials": {
|
||||
"title": "Client Zugangsdaten",
|
||||
"description": "Zugangsdaten des Clients \"{{ clientName }}\" kopieren"
|
||||
}
|
||||
},
|
||||
"userdirectory": {
|
||||
@@ -1593,22 +1624,25 @@
|
||||
"archives": {
|
||||
"listing": {
|
||||
"placeholder": "Keine archivierten Apps"
|
||||
}
|
||||
},
|
||||
"description": "Archivierte Apps bewahren die neueste Datensicherung auf, wenn sie archiviert wurde. Diese Datensicherungen werden dauerhaft aufbewahrt und können wiederhergestellt werden."
|
||||
},
|
||||
"backup": {
|
||||
"target": {
|
||||
"label": "Datensicherungsstandort",
|
||||
"size": "Größe"
|
||||
"label": "Standort",
|
||||
"size": "Größe",
|
||||
"fileCount": "Dateien"
|
||||
},
|
||||
"sites": {
|
||||
"title": "Datensicherungsstandorte",
|
||||
"emptyPlaceholder": "Keine Datensicherungsstandorte",
|
||||
"lastRun": "Letzter Lauf"
|
||||
"lastRun": "Letzter Lauf",
|
||||
"description": "Datensicherungsstandorte geben an, wo System- und App-Datensicherungen gespeichert werden. App-Datensicherungen können einzeln wiederhergestellt werden."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
"description": "Dies entfernt auch alle Datensicherungseinträge, die mit diesem Standort verbunden sind.",
|
||||
"title": "Wollen Sie diesen Datensicherungsstandort wirklich entfernen?"
|
||||
"description": "Beim Entfernen eines Datensicherungsstandorts werden dessen zugehörige Datensicherungseinträge von Cloudron gelöscht. Auf dem entfernten Zielort gespeicherte Datensicherungsdateien werden nicht gelöscht.<br/></br>Datensicherungsstandort '{{ name }}' entfernen?",
|
||||
"title": "Datensicherungsstandort entfernen"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1617,17 +1651,21 @@
|
||||
"provider": "Anbieter",
|
||||
"username": "Username",
|
||||
"title": "Docker-Registries",
|
||||
"description": "Cloudron kann benutzerdefinierte Apps aus einer privaten Docker-Registry ziehen und installieren.",
|
||||
"description": "Zugriff auf private Docker-Registries konfigurieren, um benutzerdefinierte Apps zu installieren.",
|
||||
"removeDialog": {
|
||||
"title": "{{ serverAddress }} löschen"
|
||||
"title": "Docker-Registry entfernen"
|
||||
},
|
||||
"email": "E-Mail",
|
||||
"passwordToken": "Passwort/Token",
|
||||
"emptyPlaceholder": "Keine Docker-Registries"
|
||||
"emptyPlaceholder": "Keine Docker-Registries",
|
||||
"dialog": {
|
||||
"addTitle": "Docker-Registry hinzufügen",
|
||||
"editTitle": "Docker-Registry bearbeiten"
|
||||
}
|
||||
},
|
||||
"dockerRegistres": {
|
||||
"removeDialog": {
|
||||
"description": "Möchten Sie diese Registry wirklich entfernen?"
|
||||
"description": "Docker-Registry \"{{ serverAddress }}\" entfernen?"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -47,7 +47,10 @@
|
||||
"next": "Next",
|
||||
"configure": "Configure",
|
||||
"restart": "Restart",
|
||||
"reset": "Reset"
|
||||
"reset": "Reset",
|
||||
"loadMore": "Load more",
|
||||
"setup": "Set up",
|
||||
"disable": "Disable"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"title": "Reboot Server",
|
||||
@@ -104,6 +107,9 @@
|
||||
"appNotFoundDialog": {
|
||||
"title": "App not found",
|
||||
"description": "There is no such app <b>{{ appId }}</b> with version <b>{{ version }}</b>."
|
||||
},
|
||||
"action": {
|
||||
"addCustomApp": "Add custom app"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
@@ -287,14 +293,19 @@
|
||||
"authenticatorAppDescription": "Use Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) or a similar TOTP app to scan the secret.",
|
||||
"token": "Token",
|
||||
"enable": "Enable",
|
||||
"mandatorySetup": "2FA is required to access the dashboard. Please complete the setup to continue."
|
||||
"mandatorySetup": "2FA is required to access the dashboard. Please complete the setup to continue.",
|
||||
"passkeyOption": "Passkey",
|
||||
"totpOption": "TOTP",
|
||||
"registerPasskey": "Set up passkey",
|
||||
"passkeyDescription": "The browser will prompt you to create a passkey using your device's biometrics or a password manager."
|
||||
},
|
||||
"appPasswords": {
|
||||
"title": "App Passwords",
|
||||
"app": "App",
|
||||
"name": "Name",
|
||||
"noPasswordsPlaceholder": "No app passwords",
|
||||
"description": "App passwords are a security measure to protect your Cloudron user account. If you need to access a Cloudron app from an untrusted mobile app or client, you can log in with your username and the alternate password generated here."
|
||||
"description": "App passwords are a security measure to protect your Cloudron user account. If you need to access a Cloudron app from an untrusted mobile app or client, you can log in with your username and the alternate password generated here.",
|
||||
"expires": "Expires"
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "API Tokens",
|
||||
@@ -327,7 +338,8 @@
|
||||
"name": "Password name",
|
||||
"app": "App",
|
||||
"description": "Use the following password to authenticate against the app:",
|
||||
"copyNow": "Please copy the password now. It won't be shown again for security purposes."
|
||||
"copyNow": "Please copy the password now. It won't be shown again for security purposes.",
|
||||
"expiresAt": "Expiry date"
|
||||
},
|
||||
"createApiToken": {
|
||||
"title": "Add API Token",
|
||||
@@ -338,8 +350,6 @@
|
||||
"allowedIpRanges": "Allowed IP range(s)"
|
||||
},
|
||||
"changePasswordAction": "Change password",
|
||||
"disable2FAAction": "Disable 2FA",
|
||||
"enable2FAAction": "Enable 2FA",
|
||||
"passwordResetNotification": {
|
||||
"body": "Email sent to {{ email }}"
|
||||
},
|
||||
@@ -350,6 +360,26 @@
|
||||
"removeAppPassword": {
|
||||
"title": "Remove App Password",
|
||||
"description": "Remove app password \"{{ name }}\" ?"
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Two-factor authentication",
|
||||
"totpEnabled": "Enabled",
|
||||
"passkeyEnabled": "Enabled",
|
||||
"totpTitle": "TOTP",
|
||||
"passkeyTitle": "Passkey"
|
||||
},
|
||||
"notSet": "Not set",
|
||||
"enablePasskey": {
|
||||
"title": "Enable passkey"
|
||||
},
|
||||
"enableTotp": {
|
||||
"title": "Enable TOTP"
|
||||
},
|
||||
"disableTotp": {
|
||||
"title": "Disable TOTP"
|
||||
},
|
||||
"disablePasskey": {
|
||||
"title": "Disable Passkey"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -381,7 +411,10 @@
|
||||
"date": "Created",
|
||||
"version": "Package version",
|
||||
"size": "Size",
|
||||
"duration": "Backup duration"
|
||||
"duration": "Backup duration",
|
||||
"lastIntegrityCheck": "Last integrity check",
|
||||
"integrityNever": "never",
|
||||
"integrityInProgress": "In progress"
|
||||
},
|
||||
"configureBackupSchedule": {
|
||||
"title": "Configure Backup Schedule & Retention",
|
||||
@@ -499,7 +532,9 @@
|
||||
"title": "Configure Backup Content"
|
||||
},
|
||||
"useFileAndFileNameEncryption": "File and filename encryption used",
|
||||
"useFileEncryption": "File encryption used"
|
||||
"useFileEncryption": "File encryption used",
|
||||
"checkIntegrity": "Check integrity",
|
||||
"stopIntegrity": "Stop integrity check"
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding",
|
||||
@@ -686,17 +721,16 @@
|
||||
"updateAvailableAction": "Update available",
|
||||
"stopUpdateAction": "Stop update",
|
||||
"disabled": "Disabled",
|
||||
"schedule": "Update schedule",
|
||||
"description": "Platform and app updates are applied on the configured schedule, using the <a href=\"/#/system-settings\">System time zone</a>.",
|
||||
"onLatest": "latest"
|
||||
"description": "Updates are applied on the configured schedule, using the <a href=\"/#/system-settings\">System time zone</a>.",
|
||||
"onLatest": "latest",
|
||||
"config": "Automatic updates",
|
||||
"appsOnly": "Apps only",
|
||||
"platformAndApps": "Platform & apps"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "Configure Automatic Update Schedule",
|
||||
"disableCheckbox": "Disable automatic updates",
|
||||
"enableCheckbox": "Enable automatic updates",
|
||||
"selectOne": "Select at least one day and time",
|
||||
"days": "Days",
|
||||
"hours": "Hours",
|
||||
"description": "Set the days and times for automatic platform and app updates. Ensure this schedule doesn’t overlap with backup schedules."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -716,6 +750,14 @@
|
||||
"registryConfig": {
|
||||
"provider": "Docker registry provider",
|
||||
"providerOther": "Other"
|
||||
},
|
||||
"configureUpdates": {
|
||||
"title": "Configure Automatic Updates",
|
||||
"policy": "Policy",
|
||||
"policyDescription": "Choose what gets updated automatically",
|
||||
"days": "Days",
|
||||
"hours": "Hours",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -773,7 +815,9 @@
|
||||
"changeDashboardDomain": {
|
||||
"title": "Dashboard Domain",
|
||||
"description": "Change the dashboard to the “my” subdomain of the selected domain",
|
||||
"changeAction": "Change domain"
|
||||
"changeAction": "Change domain",
|
||||
"confirmMessage": "This will invalidate all passkeys for users.",
|
||||
"confirmTitle": "Really change dashboard domain?"
|
||||
},
|
||||
"domainDialog": {
|
||||
"addTitle": "Add Domain",
|
||||
@@ -831,7 +875,9 @@
|
||||
"inwxUsername": "INWX username",
|
||||
"inwxPassword": "INWX password",
|
||||
"customNameservers": "Domain uses custom (vanity) nameservers",
|
||||
"zoneNamePlaceholder": "Optional. If not provided, defaults to the root domain."
|
||||
"zoneNamePlaceholder": "Optional. If not provided, defaults to the root domain.",
|
||||
"carddavLocation": "CardDAV server location",
|
||||
"caldavLocation": "CalDAV server location"
|
||||
},
|
||||
"removeDialog": {
|
||||
"title": "Remove Domain",
|
||||
@@ -865,12 +911,18 @@
|
||||
"appDown": "App is down",
|
||||
"rebootRequired": "Server reboot required",
|
||||
"cloudronUpdateFailed": "Cloudron update failed",
|
||||
"diskSpace": "Low disk space"
|
||||
"diskSpace": "Low disk space",
|
||||
"appAutoUpdateFailed": "App automatic update failed"
|
||||
},
|
||||
"settingsDialog": {
|
||||
"description": "An email will be sent for the selected events to your primary email."
|
||||
},
|
||||
"allCaughtUp": "All caught up"
|
||||
"allCaughtUp": "All caught up",
|
||||
"title": "Notifications",
|
||||
"showAll": "All",
|
||||
"showUnread": "Unread",
|
||||
"markUnread": "Mark as unread",
|
||||
"markRead": "Mark as read"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logs",
|
||||
@@ -894,11 +946,11 @@
|
||||
"reallyDelete": "Really delete?"
|
||||
},
|
||||
"newDirectoryDialog": {
|
||||
"title": "New Folder Name",
|
||||
"title": "New folder",
|
||||
"create": "Create"
|
||||
},
|
||||
"newFileDialog": {
|
||||
"title": "New Filename",
|
||||
"title": "New filename",
|
||||
"create": "Create"
|
||||
},
|
||||
"renameDialog": {
|
||||
@@ -916,16 +968,17 @@
|
||||
"restartApp": "Restart App",
|
||||
"uploadFolder": "Upload folder",
|
||||
"openTerminal": "Open terminal",
|
||||
"openLogs": "Open logs"
|
||||
"openLogs": "Open logs",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"extractionInProgress": "Extraction in progress",
|
||||
"pasteInProgress": "Pasting in progress",
|
||||
"deleteInProgress": "Deletion in progress",
|
||||
"chownDialog": {
|
||||
"title": "Change ownership",
|
||||
"title": "Change owner",
|
||||
"newOwner": "New owner",
|
||||
"change": "Change Owner",
|
||||
"recursiveCheckbox": "Change ownership recursively"
|
||||
"change": "Change owner",
|
||||
"recursiveCheckbox": "Change owner recursively"
|
||||
},
|
||||
"uploadingDialog": {
|
||||
"title": "Uploading files ({{ countDone }}/{{ count }})",
|
||||
@@ -1306,14 +1359,15 @@
|
||||
"packageVersion": "Package version",
|
||||
"lastUpdated": "Last updated",
|
||||
"customAppUpdateInfo": "Auto-update is not available for custom apps.",
|
||||
"installedAt": "Installed"
|
||||
"installedAt": "Installed",
|
||||
"packager": "Packager"
|
||||
},
|
||||
"auto": {
|
||||
"description": "App updates are applied periodically based on the <a href=\"/#/system-update\">update schedule</a>",
|
||||
"title": "Automatic updates"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron automatically checks the App Store for updates. You can also check manually."
|
||||
"description": "Cloudron automatically checks for app updates. You can also check manually."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -1356,11 +1410,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"description": "Apps can be stopped to conserve server resources instead of uninstalling. Future app backups will not include any app changes between now and the most recent app backup. For this reason, it is recommended to trigger a backup before stopping the app.",
|
||||
"startAction": "Start",
|
||||
"stopAction": "Stop"
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "Uninstall",
|
||||
"description": "Uninstall the app and delete its data. Backups are cleaned up according to the backup policy.",
|
||||
@@ -1472,6 +1521,16 @@
|
||||
"forumAction": "Forum",
|
||||
"appLink": {
|
||||
"title": "External Link"
|
||||
},
|
||||
"start": {
|
||||
"title": "Start",
|
||||
"description": "Start the app to make it available again.",
|
||||
"action": "Start"
|
||||
},
|
||||
"stop": {
|
||||
"action": "Stop",
|
||||
"title": "Stop",
|
||||
"description": "Stop the app to conserve resources. Back up before stopping to preserve recent changes."
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@@ -1482,7 +1541,10 @@
|
||||
"resetPasswordAction": "Reset password",
|
||||
"errorIncorrect2FAToken": "2FA token is invalid",
|
||||
"errorInternal": "Internal error, try again later",
|
||||
"loginAction": "Log in"
|
||||
"loginAction": "Log in",
|
||||
"usePasskeyAction": "Use passkey",
|
||||
"errorPasskeyFailed": "Failed to login with passkey",
|
||||
"passkeyAction": "Log in with a passkey"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Password reset",
|
||||
@@ -1639,7 +1701,8 @@
|
||||
"title": "Backup Sites",
|
||||
"emptyPlaceholder": "No backup sites",
|
||||
"lastRun": "Last run",
|
||||
"description": "Backup sites specify where system and app backups are stored. App backups can be restored individually."
|
||||
"description": "Backup sites specify where system and app backups are stored. App backups can be restored individually.",
|
||||
"noAutomaticUpdateBackupWarning": "No backup site is configured to store backups for automatic updates. Enable \"Store automatic-update backups here\" on at least one backup site to allow automatic updates."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
@@ -1678,5 +1741,9 @@
|
||||
},
|
||||
"server": {
|
||||
"title": "Server"
|
||||
},
|
||||
"communityapp": {
|
||||
"installwarning": "Community apps are not reviewed by Cloudron. Only install apps from trusted developers. Third-party code can compromise your system.",
|
||||
"unstablewarning": "This app is marked as unstable by its developer."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
"users": "Usuarios",
|
||||
"errorUserManagementSelectAtLeastOne": "Selecciona al menos un usuario o un grupo",
|
||||
"userManagementSelectUsers": "Permitir solo a los siguientes usuarios y grupos",
|
||||
"userManagementAllUsers": "Permitir a todos los usuarios de este Cloudron",
|
||||
"userManagementAllUsers": "Permitir a todos los usuarios en este Cloudron",
|
||||
"userManagementLeaveToApp": "Deja la gestión de usuarios a la aplicación",
|
||||
"userManagementMailbox": "Todos los usuarios con un buzón en este Cloudron tienen acceso.",
|
||||
"userManagementNone": "Esta aplicación tiene su propia gestión de usuarios. Esta configuración determina si esta aplicación está visible en el panel del usuario.",
|
||||
"userManagementMailbox": "Los usuarios con un <a href=\"/#/mailboxes\">buzón de correo</a> pueden iniciar sesión con el correo electrónico de su buzón y la contraseña de Cloudron.",
|
||||
"userManagementNone": "Esta aplicación tiene su propia gestión de usuarios.",
|
||||
"userManagement": "Gestión de usuarios",
|
||||
"manualWarning": "Configurar manualmente los registros DNS A (IPv4) y AAAA (IPv6) para <b>{{ location }}</b> que apuntan a este servidor",
|
||||
"locationPlaceholder": "Dejar vacío para usar solo el dominio",
|
||||
@@ -31,13 +31,16 @@
|
||||
"appNotFoundDialog": {
|
||||
"description": "No hay aplicación <b>{{ appId }}</b> con versión <b>{{ version }}</b>.",
|
||||
"title": "Aplicación no encontrada"
|
||||
},
|
||||
"action": {
|
||||
"addCustomApp": "Añadir Aplicación personalizada"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
"rebootDialog": {
|
||||
"rebootAction": "Reiniciar ahora",
|
||||
"description": "Use esto para aplicar actualizaciones de seguridad o si experimenta un comportamiento inesperado. Todas las aplicaciones y servicios que se ejecutan actualmente en este Cloudron se iniciarán automáticamente cuando se complete el reinicio.",
|
||||
"title": "¿Realmente quieres reiniciar el servidor?"
|
||||
"description": "Todas las aplicaciones y servicios se reiniciarán automáticamente.<br/><br/>¿Reiniciar el servidor ahora?",
|
||||
"title": "Reiniciar el servidor"
|
||||
},
|
||||
"action": {
|
||||
"logs": "Registros",
|
||||
@@ -45,10 +48,15 @@
|
||||
"remove": "Borrar",
|
||||
"edit": "Editar",
|
||||
"add": "Añadir",
|
||||
"next": "Siguiente"
|
||||
"next": "Siguiente",
|
||||
"configure": "Configurar",
|
||||
"restart": "Reanudar",
|
||||
"reset": "Reiniciar",
|
||||
"loadMore": "Cargar más"
|
||||
},
|
||||
"table": {
|
||||
"version": "Versión"
|
||||
"version": "Versión",
|
||||
"created": "Creado"
|
||||
},
|
||||
"actions": "Acciones",
|
||||
"displayName": "Nombre para mostrar",
|
||||
@@ -75,7 +83,13 @@
|
||||
"groups": "Grupos"
|
||||
},
|
||||
"statusEnabled": "Habilitado",
|
||||
"loadingPlaceholder": "Cargando"
|
||||
"loadingPlaceholder": "Cargando",
|
||||
"platform": {
|
||||
"startupFailed": "El inicio de la plataforma falló"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapseAction": "Contraer la barra lateral"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"searchPlaceholder": "Busca Aplicaciones",
|
||||
@@ -84,7 +98,7 @@
|
||||
"title": "Todavía no tienes acceso a ninguna aplicación."
|
||||
},
|
||||
"noApps": {
|
||||
"description": "¿Qué te parece si instalas algunas? Echa un vistazo a la <a href=\"{{ appStoreLink }}\"> Tienda de Aplicaciones</a>",
|
||||
"description": "¿Qué tal si instalas algunas? Visita la <a href=\"{{ appStoreLink }}\">App Store</a>.",
|
||||
"title": "¡No hay aplicaciones instaladas todavía!"
|
||||
},
|
||||
"title": "Mis Aplicaciones",
|
||||
@@ -105,22 +119,22 @@
|
||||
"externalLdap": {
|
||||
"errorSelfSignedCert": "El servidor está utilizando un certificado no válido o autofirmado.",
|
||||
"bindUsername": "Enlazar DN/Nombre de usuario (opcional)",
|
||||
"bindPassword": "Enlazar Contraseña (opcional)",
|
||||
"bindPassword": "Vincular contraseña (opcional)",
|
||||
"groupBaseDn": "Grupo Base DN",
|
||||
"baseDn": "DN Base",
|
||||
"configureAction": "Configurar",
|
||||
"syncAction": "Sincronizar",
|
||||
"syncAction": "Sincronizar ahora",
|
||||
"autocreateUsersOnLogin": "Crear usuarios automáticamente al iniciar sesión",
|
||||
"groupnameField": "Campo de Nombre de Grupo",
|
||||
"groupFilter": "Filtro de Grupo",
|
||||
"syncGroups": "Sincronizar Grupos",
|
||||
"syncGroups": "Sincronizar grupos",
|
||||
"usernameField": "Campo de Nombre de Usuario",
|
||||
"filter": "Filtro",
|
||||
"acceptSelfSignedCert": "Aceptar Certificado Autofirmado",
|
||||
"acceptSelfSignedCert": "Aceptar certificado autofirmado",
|
||||
"server": "URL del Servidor",
|
||||
"provider": "Proveedor",
|
||||
"noopInfo": "La autentificación LDAP no está configurada.",
|
||||
"description": "Esta configuración sincronizará y autentificará usuarios y grupos desde un servidor LDAP o Active Directory externo. La sincronización se ejecuta periódicamente pero también se puede activar manualmente.",
|
||||
"noopInfo": "No hay ningún directorio externo configurado",
|
||||
"description": "Sincroniza y autentifica usuarios y grupos desde un servidor LDAP o Active Directory externo. La sincronización se ejecuta periódicamente cada 4 horas.",
|
||||
"title": "Conectar un directorio externo",
|
||||
"auth": "Auth",
|
||||
"disableWarning": "La fuente de autentificación de todos los usuarios existentes se restablecerá para autentificarse en la base de datos de contraseñas local."
|
||||
@@ -200,11 +214,11 @@
|
||||
"title": "Borrar Usuario {{ username }}"
|
||||
},
|
||||
"user": {
|
||||
"activeCheckbox": "Usuario activo",
|
||||
"activeCheckbox": "El usuario está activo",
|
||||
"recoveryEmail": "Correo electrónico de recuperación de contraseña",
|
||||
"primaryEmail": "Email Principal",
|
||||
"displayName": "Nombre para mostrar",
|
||||
"usernamePlaceholder": "Opcional. Si no se proporciona, el usuario puede elegirlo durante el registro",
|
||||
"usernamePlaceholder": "Opcional. Si no se proporciona, el usuario puede elegirlo durante el registro.",
|
||||
"noGroups": "No hay grupos disponibles.",
|
||||
"groups": "Grupos",
|
||||
"role": "Rol",
|
||||
@@ -388,8 +402,6 @@
|
||||
"useFileEncryption": "Se usa cifrado de archivos"
|
||||
},
|
||||
"profile": {
|
||||
"enable2FAAction": "Habilita 2FA",
|
||||
"disable2FAAction": "Deshabilita 2FA",
|
||||
"changePasswordAction": "Cambiar contraseña",
|
||||
"createApiToken": {
|
||||
"copyNow": "Por favor copia el token API ahora. No se volverá a mostrar por motivos de seguridad.",
|
||||
@@ -646,12 +658,9 @@
|
||||
"title": "Ajustes",
|
||||
"updateScheduleDialog": {
|
||||
"description": "Establece los días y horarios para las actualizaciones automáticas de la plataforma y la aplicación. Asegúrate de que esta programación no coincida con la programación de las copias de seguridad.",
|
||||
"hours": "Horas",
|
||||
"days": "Días",
|
||||
"selectOne": "Seleccione al menos un día y una hora",
|
||||
"enableCheckbox": "Habilitar Actualizaciones Automáticas",
|
||||
"disableCheckbox": "Desactivar las Actualizaciones Automáticas",
|
||||
"title": "Configurar la programación de las Actualizaciones Automáticas"
|
||||
"disableCheckbox": "Desactivar las Actualizaciones Automáticas"
|
||||
},
|
||||
"updates": {
|
||||
"stopUpdateAction": "Parar Actualización",
|
||||
@@ -660,7 +669,6 @@
|
||||
"title": "Actualizaciones",
|
||||
"description": "Las actualizaciones de la plataforma y de la aplicación se aplican según el cronograma establecido aquí, utilizando la <a href=\"/#/system-settings\">Zona horaria del sistema</a>.",
|
||||
"disabled": "Deshabilitado",
|
||||
"schedule": "Programar",
|
||||
"onLatest": "el último"
|
||||
},
|
||||
"language": {
|
||||
@@ -970,11 +978,6 @@
|
||||
"uninstallAction": "Desinstalar",
|
||||
"title": "Desinstalar",
|
||||
"description": "Esto desinstalará la aplicación y eliminará sus datos. Las copias de seguridad se limpiarán según la política de copias de seguridad."
|
||||
},
|
||||
"startStop": {
|
||||
"startAction": "Arrancar",
|
||||
"stopAction": "Parar",
|
||||
"description": "Las aplicaciones se pueden detener para conservar los recursos del servidor en lugar de desinstalarlas. Las futuras copias de seguridad de la aplicación no incluirán ningún cambio en la aplicación entre ahora y la copia de seguridad de la aplicación más reciente. Por este motivo, se recomienda activar una copia de seguridad antes de detener la aplicación."
|
||||
}
|
||||
},
|
||||
"cloneDialog": {
|
||||
|
||||
@@ -263,8 +263,6 @@
|
||||
"title": "Jetons de connexion",
|
||||
"description": "Vous avez {{ webadminTokens.length }} jeton(s) web actif(s) et {{ cliTokens.length }} jeton(s) CLI."
|
||||
},
|
||||
"disable2FAAction": "Désactiver l'authentification à deux facteurs (2FA)",
|
||||
"enable2FAAction": "Activer l'authentification à deux facteurs (2FA)",
|
||||
"passwordResetNotification": {
|
||||
"body": "Email envoyé à {{ email }}"
|
||||
}
|
||||
@@ -517,12 +515,9 @@
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"description": "Sélectionnez les jours et heures de lancement des mises à jour de la plateforme et des applications. Veillez à ne pas planifier les mises à jour au même moment que la <a href=\"/#/backups\">sauvegarde</a>.",
|
||||
"hours": "Heures",
|
||||
"days": "Jours",
|
||||
"selectOne": "Sélectionnez au moins un jour et une heure",
|
||||
"enableCheckbox": "Activer les mises à jour automatiques",
|
||||
"disableCheckbox": "Désactiver les mises à jour automatiques",
|
||||
"title": "Planification des mises à jour automatiques"
|
||||
"disableCheckbox": "Désactiver les mises à jour automatiques"
|
||||
},
|
||||
"updates": {
|
||||
"stopUpdateAction": "Interrompre la mise à jour",
|
||||
@@ -714,11 +709,6 @@
|
||||
"description": "Cette action entraînera la désinstallation immédiate de l'application et la suppression de l'ensemble de ses données. Le site sera inaccessible.",
|
||||
"uninstallAction": "Désinstaller",
|
||||
"title": "Désinstaller"
|
||||
},
|
||||
"startStop": {
|
||||
"description": "Pour économiser les ressources du serveur, vous pouvez mettre en pause les applications au lieu de les désinstaller. Les futures sauvegardes d'applications ne comprendront pas les modifications apportées aux applications entre aujourd'hui et la dernière sauvegarde. Pour cette raison, il est recommandé de lancer une sauvegarde avant de mettre une application en pause.",
|
||||
"stopAction": "Arrêter l'application",
|
||||
"startAction": "Démarrer l'application"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"edit": "Edit"
|
||||
},
|
||||
"table": {
|
||||
"version": "Versi"
|
||||
"version": "Versi",
|
||||
"created": "Dibuat"
|
||||
},
|
||||
"logout": "Keluar",
|
||||
"action": {
|
||||
@@ -42,7 +43,8 @@
|
||||
"configure": "Konfigurasi",
|
||||
"restart": "Mulai ulang",
|
||||
"reset": "Atur Ulang",
|
||||
"logs": "Log"
|
||||
"logs": "Log",
|
||||
"loadMore": "Muat lebih banyak"
|
||||
},
|
||||
"searchPlaceholder": "Cari",
|
||||
"actions": "Tindakan",
|
||||
@@ -87,7 +89,7 @@
|
||||
"userManagementAllUsers": "Izinkan semua pengguna di Cloudron ini",
|
||||
"userManagementSelectUsers": "Hanya izinkan pengguna dan grup berikut ini",
|
||||
"errorUserManagementSelectAtLeastOne": "Pilih setidaknya satu pengguna atau grup",
|
||||
"configuredForCloudronEmail": "Aplikasi ini telah dikonfigurasi sebelumnya untuk digunakan dengan <a href=\"{{ emailDocsLink }}\" target=\"_blank\">E-mail Cloudron </a>.",
|
||||
"configuredForCloudronEmail": "Aplikasi ini telah dikonfigurasi sebelumnya untuk digunakan dengan <a href=\"{{ emailDocsLink }}\" target=\"_blank\">E-mail Cloudron</a>.",
|
||||
"cloudflarePortWarning": "Proksi Cloudflare harus dinonaktifkan agar domain aplikasi dapat mengakses port ini",
|
||||
"portReadOnly": "hanya baca",
|
||||
"ephemeralPortWarning": "Menggunakan port dinamis dapat menyebabkan konflik yang tidak terduga."
|
||||
@@ -103,7 +105,10 @@
|
||||
},
|
||||
"unstable": "Tidak stabil",
|
||||
"title": "Toko Aplikasi",
|
||||
"searchPlaceholder": "Cari alternatif seperti GitHub, Dropbox, Slack, Trello, …"
|
||||
"searchPlaceholder": "Cari alternatif seperti GitHub, Dropbox, Slack, Trello, …",
|
||||
"action": {
|
||||
"addCustomApp": "Tambahkan aplikasi kustom"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"users": {
|
||||
@@ -275,11 +280,12 @@
|
||||
"app": "Aplikasi",
|
||||
"title": "Kata sandi Aplikasi",
|
||||
"noPasswordsPlaceholder": "Tidak ada kata sandi aplikasi",
|
||||
"description": "Kata sandi aplikasi adalah langkah keamanan untuk melindungi akun pengguna Cloudron Anda. Jika Anda perlu mengakses aplikasi Cloudron dari aplikasi seluler atau klien yang tidak tepercaya, Anda dapat masuk dengan nama pengguna Anda dan kata sandi alternatif yang dihasilkan di sini."
|
||||
"description": "Kata sandi aplikasi adalah langkah keamanan untuk melindungi akun pengguna Cloudron Anda. Jika Anda perlu mengakses aplikasi Cloudron dari aplikasi seluler atau klien yang tidak tepercaya, Anda dapat masuk dengan nama pengguna Anda dan kata sandi alternatif yang dihasilkan di sini.",
|
||||
"expires": "Kadaluarsa"
|
||||
},
|
||||
"apiTokens": {
|
||||
"name": "Nama",
|
||||
"lastUsed": "Terakhir Digunakan",
|
||||
"lastUsed": "Terakhir digunakan",
|
||||
"title": "Token API",
|
||||
"scope": "Cakupan",
|
||||
"description": "Gunakan token akses pribadi ini untuk melakukan otentikasi dengan <a target=\"_blank\" href=\"{{ apiDocsLink }}\">API Cloudron</a>.",
|
||||
@@ -295,7 +301,11 @@
|
||||
"token": "Token",
|
||||
"enable": "Aktifkan",
|
||||
"mandatorySetup": "2FA diperlukan untuk mengakses dasbor. Silakan selesaikan pengaturan untuk melanjutkan.",
|
||||
"authenticatorAppDescription": "Gunakan Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) atau aplikasi TOTP serupa untuk memindai kode rahasia."
|
||||
"authenticatorAppDescription": "Gunakan Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) atau aplikasi TOTP serupa untuk memindai kode rahasia.",
|
||||
"passkeyOption": "Passkey",
|
||||
"totpOption": "TOTP",
|
||||
"registerPasskey": "Siapkan passkey",
|
||||
"passkeyDescription": "Browser akan meminta Anda untuk membuat passkey menggunakan biometrik perangkat Anda atau pengelola kata sandi."
|
||||
},
|
||||
"language": "Bahasa",
|
||||
"loginTokens": {
|
||||
@@ -313,10 +323,9 @@
|
||||
"title": "Tambahkan Kata Sandi Aplikasi",
|
||||
"name": "Nama kata sandi",
|
||||
"description": "Gunakan kata sandi berikut untuk mengautentikasi terhadap aplikasi:",
|
||||
"copyNow": "Silakan salin kata sandi sekarang. Kata sandi ini tidak akan ditampilkan lagi untuk alasan keamanan."
|
||||
"copyNow": "Silakan salin kata sandi sekarang. Kata sandi ini tidak akan ditampilkan lagi untuk alasan keamanan.",
|
||||
"expiresAt": "Tanggal kedaluwarsa"
|
||||
},
|
||||
"disable2FAAction": "Nonaktifkan 2FA",
|
||||
"enable2FAAction": "Aktifkan 2FA",
|
||||
"title": "Profil",
|
||||
"primaryEmail": "E-mail utama",
|
||||
"passwordRecoveryEmail": "E-mail pemulihan kata sandi",
|
||||
@@ -349,6 +358,11 @@
|
||||
"removeAppPassword": {
|
||||
"title": "Hapus Kata sandi Aplikasi",
|
||||
"description": "Hapus kata sandi aplikasi \"{{ name }}\"?"
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Autentikasi dua faktor",
|
||||
"totpEnabled": "Menggunakan kata sandi sekali pakai berbasis waktu (TOTP)",
|
||||
"passkeyEnabled": "Menggunakan passkey"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -362,15 +376,19 @@
|
||||
"tooltipDownloadBackupConfig": "Unduh konfigurasi",
|
||||
"cleanupBackups": "Bersihkan cadangan",
|
||||
"tooltipPreservedBackup": "Cadangan ini akan dipertahankan",
|
||||
"title": "Pencadangan Sistem"
|
||||
"title": "Pencadangan Sistem",
|
||||
"description": "Cadangan sistem berisi konfigurasi Cloudron dan metadata instalasi aplikasi. Cadangan ini dapat digunakan untuk <a href=\"{{restoreLink}}\" target=\"_blank\">memulihkan</a> atau <a href=\"{{migrateLink}}\" target=\"_blank\">memigrasikan</a> seluruh instalasi Cloudron ke server lain."
|
||||
},
|
||||
"backupDetails": {
|
||||
"duration": "Durasi",
|
||||
"version": "Versi",
|
||||
"duration": "Durasi cadangan",
|
||||
"version": "Versi paket",
|
||||
"title": "Detail Cadangan",
|
||||
"id": "Id",
|
||||
"date": "Tanggal",
|
||||
"size": "Ukuran"
|
||||
"id": "ID Cadangan",
|
||||
"date": "Dibuat",
|
||||
"size": "Ukuran",
|
||||
"lastIntegrityCheck": "Pemeriksaan integritas terakhir",
|
||||
"integrityNever": "tidak pernah",
|
||||
"integrityInProgress": "Sedang diproses"
|
||||
},
|
||||
"configureBackupSchedule": {
|
||||
"hours": "Jam",
|
||||
@@ -497,7 +515,9 @@
|
||||
"title": "Konfigurasi Konten Cadangan"
|
||||
},
|
||||
"useFileAndFileNameEncryption": "Enkripsi berkas dan nama berkas digunakan",
|
||||
"useFileEncryption": "Enkripsi berkas digunakan"
|
||||
"useFileEncryption": "Enkripsi berkas digunakan",
|
||||
"checkIntegrity": "Periksa integritas",
|
||||
"stopIntegrity": "Hentikan pemeriksaan integritas"
|
||||
},
|
||||
"branding": {
|
||||
"logo": "Logo",
|
||||
@@ -704,17 +724,13 @@
|
||||
"updateAvailableAction": "Pembaruan tersedia",
|
||||
"stopUpdateAction": "Hentikan pembaruan",
|
||||
"disabled": "Dinonaktifkan",
|
||||
"schedule": "Jadwal pembaruan",
|
||||
"description": "Pembaruan platform dan aplikasi diterapkan sesuai jadwal yang telah dikonfigurasi, menggunakan <a href=\"/#/system-settings\">Zona waktu sistem</a>.",
|
||||
"onLatest": "terbaru"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "Konfigurasi Jadwal Pembaruan Otomatis",
|
||||
"disableCheckbox": "Nonaktifkan pembaruan otomatis",
|
||||
"enableCheckbox": "Aktifkan pembaruan otomatis",
|
||||
"selectOne": "Pilih setidaknya satu hari dan satu waktu",
|
||||
"days": "Hari",
|
||||
"hours": "Jam",
|
||||
"description": "Atur hari dan waktu untuk pembaruan otomatis platform dan aplikasi. Pastikan jadwal ini tidak tumpang tindih dengan jadwal pencadangan."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -791,7 +807,9 @@
|
||||
"changeDashboardDomain": {
|
||||
"title": "Dasbor Domain",
|
||||
"description": "Ubah dashboard ke subdomain 'my' pada domain yang dipilih",
|
||||
"changeAction": "Ubah domain"
|
||||
"changeAction": "Ubah domain",
|
||||
"confirmMessage": "Ini akan membatalkan semua passkey untuk pengguna.",
|
||||
"confirmTitle": "Apakah Anda benar-benar ingin mengubah domain dasbor?"
|
||||
},
|
||||
"domainDialog": {
|
||||
"addTitle": "Tambahkan Domain",
|
||||
@@ -849,7 +867,9 @@
|
||||
"inwxUsername": "Nama pengguna INWX",
|
||||
"inwxPassword": "Kata sandi INWX",
|
||||
"customNameservers": "Domain menggunakan nameserver kustom (vanity)",
|
||||
"zoneNamePlaceholder": "Opsional. Jika tidak disediakan, akan menggunakan domain utama sebagai bawaan."
|
||||
"zoneNamePlaceholder": "Opsional. Jika tidak disediakan, akan menggunakan domain utama sebagai bawaan.",
|
||||
"carddavLocation": "Lokasi server CardDAV",
|
||||
"caldavLocation": "Lokasi server CalDAV"
|
||||
},
|
||||
"removeDialog": {
|
||||
"title": "Hapus Domain",
|
||||
@@ -888,11 +908,11 @@
|
||||
"reallyDelete": "Apakah Anda yakin ingin menghapus?"
|
||||
},
|
||||
"newDirectoryDialog": {
|
||||
"title": "Nama Folder Baru",
|
||||
"title": "Folder Baru",
|
||||
"create": "Buat"
|
||||
},
|
||||
"newFileDialog": {
|
||||
"title": "Nama berkas Baru",
|
||||
"title": "Nama berkas baru",
|
||||
"create": "Buat"
|
||||
},
|
||||
"renameDialog": {
|
||||
@@ -910,16 +930,17 @@
|
||||
"restartApp": "Mulai ulang Aplikasi",
|
||||
"uploadFolder": "Unggah folder",
|
||||
"openTerminal": "Buka terminal",
|
||||
"openLogs": "Buka log"
|
||||
"openLogs": "Buka log",
|
||||
"refresh": "Segarkan"
|
||||
},
|
||||
"extractionInProgress": "Ekstraksi sedang berlangsung",
|
||||
"pasteInProgress": "Penempelan sedang berlangsung",
|
||||
"deleteInProgress": "Penghapusan sedang berlangsung",
|
||||
"chownDialog": {
|
||||
"title": "Ubah kepemilikan",
|
||||
"title": "Ubah pemilik",
|
||||
"newOwner": "Pemilik baru",
|
||||
"change": "Ubah Pemilik",
|
||||
"recursiveCheckbox": "Ubah kepemilikan secara rekursif"
|
||||
"change": "Ubah pemilik",
|
||||
"recursiveCheckbox": "Ubah pemilik secara rekursif"
|
||||
},
|
||||
"uploadingDialog": {
|
||||
"title": "Mengunggah berkas ({{ countDone }}/{{ count }})",
|
||||
@@ -1163,7 +1184,7 @@
|
||||
},
|
||||
"accessControl": {
|
||||
"userManagement": {
|
||||
"description": "Konfigurasikan siapa yang dapat masuk dan menggunakan aplikasi.",
|
||||
"description": "Konfigurasikan siapa yang dapat masuk dan menggunakan aplikasi",
|
||||
"descriptionSftp": "Pengaturan ini juga mengontrol akses SFTP.",
|
||||
"dashboardVisibility": "Visibilitas Dasbor",
|
||||
"visibleForAllUsers": "Terlihat oleh semua pengguna di Cloudron ini",
|
||||
@@ -1177,7 +1198,7 @@
|
||||
},
|
||||
"operators": {
|
||||
"title": "Operator",
|
||||
"description": "Para operator dapat mengonfigurasi dan memelihara aplikasi ini."
|
||||
"description": "Konfigurasikan siapa yang dapat memelihara aplikasi"
|
||||
},
|
||||
"dashboardVisibility": {
|
||||
"description": "Konfigurasikan siapa yang dapat melihat aplikasi ini di dasbor."
|
||||
@@ -1282,7 +1303,7 @@
|
||||
"cron": {
|
||||
"title": "Crontab",
|
||||
"saveAction": "Simpan",
|
||||
"addCommonPattern": "Tambahkan pola umum",
|
||||
"addCommonPattern": "Masukkan pola umum",
|
||||
"commonPattern": {
|
||||
"everyMinute": "Setiap Menit",
|
||||
"everyHour": "Setiap Jam",
|
||||
@@ -1334,13 +1355,29 @@
|
||||
"accessControlTabTitle": "Kontrol Akses",
|
||||
"security": {
|
||||
"csp": {
|
||||
"description": "Timpa semua header CSP yang ditentukan oleh aplikasi.",
|
||||
"description": "Timpa semua header CSP yang ditentukan oleh aplikasi",
|
||||
"title": "Kebijakan Keamanan Konten",
|
||||
"saveAction": "Simpan"
|
||||
"saveAction": "Simpan",
|
||||
"insertCommonCsp": "Masukkan CSP umum",
|
||||
"commonPattern": {
|
||||
"allowEmbedding": "Izinkan penyematan",
|
||||
"sameOriginEmbedding": "Izinkan penyematan (hanya subdomain)",
|
||||
"allowCdnAssets": "Izinkan aset CDN",
|
||||
"reportOnly": "Laporkan pelanggaran CSP",
|
||||
"strictBaseline": "Baseline yang ketat"
|
||||
}
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"description": "Secara bawaan, bot dapat mengindeks aplikasi ini."
|
||||
"description": "Secara bawaan, bot dapat mengindeks aplikasi ini",
|
||||
"commonPattern": {
|
||||
"allowAll": "Izinkan semua (bawaan)",
|
||||
"disallowAll": "Larang semua",
|
||||
"disallowCommonBots": "Larang bot umum",
|
||||
"disallowAdminPaths": "Larang akses jalur admin",
|
||||
"disallowApiPaths": "Larang jalur API"
|
||||
},
|
||||
"insertCommonRobotsTxt": "Masukkan robots.txt umum"
|
||||
},
|
||||
"hstsPreload": "Aktifkan HSTS Preload (termasuk subdomain)"
|
||||
},
|
||||
@@ -1351,20 +1388,21 @@
|
||||
"packageVersion": "Versi paket",
|
||||
"lastUpdated": "Terakhir diperbarui",
|
||||
"customAppUpdateInfo": "Pembaruan otomatis tidak tersedia untuk aplikasi khusus.",
|
||||
"installedAt": "Terpasang"
|
||||
"installedAt": "Terpasang",
|
||||
"packager": "Pengemas"
|
||||
},
|
||||
"auto": {
|
||||
"description": "Pembaruan aplikasi diterapkan secara berkala berdasarkan <a href=\"/#/system-update\">jadwal pembaruan</a>",
|
||||
"title": "Pembaruan otomatis"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron secara otomatis memeriksa pembaruan di App Store. Anda juga dapat memeriksanya secara manual."
|
||||
"description": "Cloudron secara otomatis memeriksa pembaruan. Anda juga dapat memeriksanya secara manual."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
"backups": {
|
||||
"title": "Cadangan",
|
||||
"description": "Buat snapshot lengkap dari aplikasi tersebut.",
|
||||
"description": "Buat snapshot lengkap dari aplikasi tersebut",
|
||||
"downloadConfigTooltip": "Unduh konfigurasi",
|
||||
"cloneTooltip": "Klon",
|
||||
"restoreTooltip": "Pulihkan",
|
||||
@@ -1375,7 +1413,7 @@
|
||||
},
|
||||
"import": {
|
||||
"title": "Impor",
|
||||
"description": "Impor aplikasi dari cadangan eksternal."
|
||||
"description": "Impor aplikasi dari cadangan eksternal"
|
||||
},
|
||||
"auto": {
|
||||
"title": "Cadangan otomatis",
|
||||
@@ -1401,11 +1439,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"startAction": "Mulai",
|
||||
"stopAction": "Berhenti",
|
||||
"description": "Aplikasi dapat dihentikan untuk menghemat sumber daya server daripada menghapusnya. Cadangan aplikasi di masa mendatang tidak akan mencakup perubahan pada aplikasi antara sekarang dan cadangan aplikasi terbaru. Oleh karena itu, disarankan untuk memicu cadangan sebelum menghentikan aplikasi."
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "Hapus instalasi",
|
||||
"description": "Hapus instalasi aplikasi dan hapus datanya. Cadangan dibersihkan sesuai dengan kebijakan pencadangan.",
|
||||
@@ -1450,7 +1483,17 @@
|
||||
"title": "Arsipkan Aplikasi",
|
||||
"description": "Hapus aplikasi {{ app }} dan pindahkan cadangan terbarunya (dibuat pada {{ date }}) ke arsip aplikasi?"
|
||||
},
|
||||
"updateAvailableTooltip": "Pembaruan tersedia"
|
||||
"updateAvailableTooltip": "Pembaruan tersedia",
|
||||
"start": {
|
||||
"title": "Mulai",
|
||||
"description": "Mulai aplikasi untuk membuatnya tersedia kembali.",
|
||||
"action": "Mulai"
|
||||
},
|
||||
"stop": {
|
||||
"action": "Berhenti",
|
||||
"title": "Berhenti",
|
||||
"description": "Hentikan aplikasi untuk menghemat sumber daya. Cadangkan sebelum menghentikan untuk mempertahankan perubahan terakhir."
|
||||
}
|
||||
},
|
||||
"setupAccount": {
|
||||
"errorPassword": "Kata sandi harus setidaknya 8 karakter",
|
||||
@@ -1573,7 +1616,8 @@
|
||||
"archives": {
|
||||
"listing": {
|
||||
"placeholder": "Tidak ada aplikasi yang diarsipkan"
|
||||
}
|
||||
},
|
||||
"description": "Aplikasi yang diarsipkan menyimpan cadangan terbaru saat aplikasi tersebut diarsipkan. Cadangan ini disimpan secara permanen dan dapat dipulihkan."
|
||||
},
|
||||
"backup": {
|
||||
"target": {
|
||||
@@ -1584,7 +1628,9 @@
|
||||
"sites": {
|
||||
"title": "Situs Cadangan",
|
||||
"emptyPlaceholder": "Tidak ada situs cadangan",
|
||||
"lastRun": "Terakhir dijalankan"
|
||||
"lastRun": "Terakhir dijalankan",
|
||||
"description": "Lokasi cadangan menunjukkan di mana cadangan sistem dan cadangan aplikasi disimpan. Cadangan aplikasi dapat dipulihkan secara terpisah.",
|
||||
"noAutomaticUpdateBackupWarning": "Tidak ada situs cadangan yang dikonfigurasi untuk menyimpan cadangan pembaruan otomatis. Aktifkan \"Simpan cadangan pembaruan otomatis di sini\" pada setidaknya satu situs cadangan untuk memungkinkan pembaruan otomatis."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
@@ -1621,7 +1667,12 @@
|
||||
"settingsDialog": {
|
||||
"description": "E-mail akan dikirimkan ke e-mail utama Anda untuk acara-acara yang dipilih."
|
||||
},
|
||||
"allCaughtUp": "Semua sudah ditangani"
|
||||
"allCaughtUp": "Semua sudah ditangani",
|
||||
"title": "Notifikasi",
|
||||
"showAll": "Semua",
|
||||
"showUnread": "Belum dibaca",
|
||||
"markUnread": "Tandai sebagai belum dibaca",
|
||||
"markRead": "Tandai sebagai sudah dibaca"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Log",
|
||||
@@ -1636,7 +1687,8 @@
|
||||
"resetPasswordAction": "Atur ulang kata sandi",
|
||||
"errorIncorrect2FAToken": "Token 2FA tidak valid",
|
||||
"errorInternal": "Terjadi kesalahan internal, coba lagi nanti",
|
||||
"loginAction": "Masuk"
|
||||
"loginAction": "Masuk",
|
||||
"usePasskeyAction": "Gunakan passkey"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Pengaturan ulang kata sandi",
|
||||
@@ -1658,5 +1710,9 @@
|
||||
"title": "Kata sandi telah diubah",
|
||||
"openDashboardAction": "Buka dasbor"
|
||||
}
|
||||
},
|
||||
"communityapp": {
|
||||
"installwarning": "Aplikasi komunitas tidak ditinjau oleh Cloudron. Hanya instal aplikasi dari pengembang tepercaya. Kode pihak ketiga dapat membahayakan sistem Anda.",
|
||||
"unstablewarning": "Aplikasi ini ditandai sebagai tidak stabil oleh pengembangnya."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,11 +169,6 @@
|
||||
"uninstallAction": "Disinstalla",
|
||||
"description": "Questo disinstallerà immediatamente l'app e rimuoverà tutti i suoi dati. Il sito sarà inaccessibile.",
|
||||
"title": "Disinstalla"
|
||||
},
|
||||
"startStop": {
|
||||
"stopAction": "Ferma App",
|
||||
"startAction": "Avvia App",
|
||||
"description": "Le app possono essere interrotte per risparmiare le risorse del server invece di disinstallarle. I backup futuri delle app non includeranno alcuna modifica dell'app da adesso fino al backup dell'app più recente. Per questo motivo, si consiglia di fare un backup prima di arrestare l'app."
|
||||
}
|
||||
},
|
||||
"repair": {
|
||||
@@ -565,8 +560,6 @@
|
||||
"title": "Backup"
|
||||
},
|
||||
"profile": {
|
||||
"enable2FAAction": "Abilita 2FA",
|
||||
"disable2FAAction": "Disabilita 2FA",
|
||||
"changePasswordAction": "Cambia Password",
|
||||
"createApiToken": {
|
||||
"copyNow": "Copia il token API ora. Non verrà mostrato di nuovo per motivi di sicurezza.",
|
||||
@@ -787,12 +780,9 @@
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"description": "Seleziona i giorni e gli orari durante i quali Cloudron applicherà gli aggiornamenti automatici della piattaforma e dell'app. Fai attenzione a non sovrapporre questa pianificazione alla <a href=\"/#/backups\">pianificazione dei backup</a>.",
|
||||
"hours": "Ore",
|
||||
"days": "Giorni",
|
||||
"selectOne": "Seleziona almeno un giorno e un'ora",
|
||||
"enableCheckbox": "Abilita Aggiornamenti Automatici",
|
||||
"disableCheckbox": "Disabilita Aggiornamenti Automatici",
|
||||
"title": "Configura pianificazione aggiornamenti automatici"
|
||||
"disableCheckbox": "Disabilita Aggiornamenti Automatici"
|
||||
},
|
||||
"updates": {
|
||||
"stopUpdateAction": "Ferma Aggiornamento",
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"next": "Volgende",
|
||||
"configure": "Configureer",
|
||||
"restart": "Herstart",
|
||||
"reset": "Reset"
|
||||
"reset": "Reset",
|
||||
"loadMore": "Laad meer"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"title": "Herstart Server",
|
||||
@@ -104,6 +105,9 @@
|
||||
"appNotFoundDialog": {
|
||||
"title": "App niet gevonden",
|
||||
"description": "De app <b>{{ appId }}</b> met versie <b>{{ version }}</b> bestaat niet."
|
||||
},
|
||||
"action": {
|
||||
"addCustomApp": "Aangepaste app toevoegen"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
@@ -287,14 +291,19 @@
|
||||
"enable": "Inschakelen",
|
||||
"title": "Schakel Twee-Factor (2FA) authenticatie in",
|
||||
"authenticatorAppDescription": "Gebruik Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) of vergelijkbare Twee-Factor (2FA) authenticatie app om de QR-code te scannen.",
|
||||
"mandatorySetup": "2FA is noodzakelijk voor toegang tot het dashboard. Vervolg het instellen om door te gaan."
|
||||
"mandatorySetup": "2FA is noodzakelijk voor toegang tot het dashboard. Vervolg het instellen om door te gaan.",
|
||||
"passkeyOption": "Passkey",
|
||||
"totpOption": "TOTP",
|
||||
"registerPasskey": "Instellen passkey",
|
||||
"passkeyDescription": "De browser zal je vragen een passkey aan te maken met de biometrie van je apparaat of via een wachtwoordbeheerder."
|
||||
},
|
||||
"appPasswords": {
|
||||
"app": "App",
|
||||
"name": "Naam",
|
||||
"noPasswordsPlaceholder": "Geen app-wachtwoorden",
|
||||
"title": "App wachtwoorden",
|
||||
"description": "App wachtwoorden zijn een veiligheidsmiddel om je Cloudronaccount te beschermen. Indien je toegang wilt tot een Cloudron-app met een niet-vertrouwde mobiele app of andere software, kun je inloggen met je gebruikersnaam en app wachtwoord die je hier kunt aanmaken."
|
||||
"description": "App wachtwoorden zijn een veiligheidsmiddel om je Cloudronaccount te beschermen. Indien je toegang wilt tot een Cloudron-app met een niet-vertrouwde mobiele app of andere software, kun je inloggen met je gebruikersnaam en app wachtwoord die je hier kunt aanmaken.",
|
||||
"expires": "Verloopt"
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "API Tokens",
|
||||
@@ -327,7 +336,8 @@
|
||||
"app": "App",
|
||||
"description": "Het volgende wachtwoord is gegenereerd voor de app:",
|
||||
"name": "Beschrijving van het wachtwoord",
|
||||
"copyNow": "Let op: kopieer het wachtwoord nu, vanwege veiligheidsredenen wordt het nooit meer getoond."
|
||||
"copyNow": "Let op: kopieer het wachtwoord nu, vanwege veiligheidsredenen wordt het nooit meer getoond.",
|
||||
"expiresAt": "Vervaldatum"
|
||||
},
|
||||
"createApiToken": {
|
||||
"title": "API Token aanmaken",
|
||||
@@ -338,8 +348,6 @@
|
||||
"allowedIpRanges": "Toegestane IP range(s)"
|
||||
},
|
||||
"changePasswordAction": "Verander Wachtwoord",
|
||||
"disable2FAAction": "Twee-Factor (2FA) authenticatie uitschakelen",
|
||||
"enable2FAAction": "Twee-Factor (2FA) authenticatie inschakelen",
|
||||
"passwordResetNotification": {
|
||||
"body": "E-mail gestuurd naar {{ email }}"
|
||||
},
|
||||
@@ -350,6 +358,11 @@
|
||||
"removeAppPassword": {
|
||||
"title": "Verwijder app-wachtwoord",
|
||||
"description": "Verwijder App-wachtwoord \"{{ name }}\"?"
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Twee-Factor (2FA) authenticatie",
|
||||
"totpEnabled": "Gebruikt tijdgebaseerd eenmalige wachtwoord (TOTP)",
|
||||
"passkeyEnabled": "Gebruikt passkey"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -381,7 +394,10 @@
|
||||
"date": "Aangemaakt",
|
||||
"version": "Package versie",
|
||||
"size": "Grootte",
|
||||
"duration": "Backup duur"
|
||||
"duration": "Backup duur",
|
||||
"lastIntegrityCheck": "Laatste integriteitscontrole",
|
||||
"integrityNever": "nooit",
|
||||
"integrityInProgress": "In uitvoering"
|
||||
},
|
||||
"configureBackupSchedule": {
|
||||
"title": "Configureer Backup Planning & Bewaartermijn",
|
||||
@@ -499,7 +515,9 @@
|
||||
"title": "Configureer Backup Inhoud"
|
||||
},
|
||||
"useFileAndFileNameEncryption": "Bestand en bestandsnaam encryptie gebruikt",
|
||||
"useFileEncryption": "Bestand encryptie gebruikt"
|
||||
"useFileEncryption": "Bestand encryptie gebruikt",
|
||||
"checkIntegrity": "Controleer integriteit",
|
||||
"stopIntegrity": "Stop integriteitscontrole"
|
||||
},
|
||||
"branding": {
|
||||
"title": "Huisstijl",
|
||||
@@ -653,7 +671,9 @@
|
||||
"inwxUsername": "INWX gebruikersnaam",
|
||||
"inwxPassword": "INWX wachtwoord",
|
||||
"customNameservers": "Domein maakt gebruik van aangepaste (eigen) naamservers",
|
||||
"zoneNamePlaceholder": "Optioneel. Indien niet opgegeven, wordt standaard het rootdomein gebruikt."
|
||||
"zoneNamePlaceholder": "Optioneel. Indien niet opgegeven, wordt standaard het rootdomein gebruikt.",
|
||||
"carddavLocation": "CardDAV-server locatie",
|
||||
"caldavLocation": "CalDAV server locatie"
|
||||
},
|
||||
"title": "Domeinen",
|
||||
"domain": "Domein",
|
||||
@@ -666,7 +686,9 @@
|
||||
"changeDashboardDomain": {
|
||||
"changeAction": "Domein aanpassen",
|
||||
"title": "Dashboard Domein",
|
||||
"description": "Verander het Dashboard naar het “my” subdomein van het geselecteerde domein"
|
||||
"description": "Verander het Dashboard naar het “my” subdomein van het geselecteerde domein",
|
||||
"confirmMessage": "Dit zal alle passkeys voor gebruikers ongeldig maken.",
|
||||
"confirmTitle": "Wil je echt het dashboard-domein wijzigen?"
|
||||
},
|
||||
"removeDialog": {
|
||||
"title": "Verwijder domein",
|
||||
@@ -855,14 +877,15 @@
|
||||
"packageVersion": "Pakketversie",
|
||||
"lastUpdated": "Laatst geüpdatet",
|
||||
"customAppUpdateInfo": "Auto-update is niet beschikbaar voor maatwerk apps.",
|
||||
"installedAt": "Geïnstalleerd"
|
||||
"installedAt": "Geïnstalleerd",
|
||||
"packager": "Pakketmaker"
|
||||
},
|
||||
"auto": {
|
||||
"description": "App updates worden uitgevoerd op basis van de <a href=\"/#/system-update\">update planning</a>.",
|
||||
"title": "Automatische updates"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron controleert automatisch de App Store op updates. Je kunt ook handmatig controleren."
|
||||
"description": "Cloudron controleert automatisch op app-updates. Je kunt dit ook handmatig controleren."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -905,11 +928,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"startAction": "Start",
|
||||
"stopAction": "Stop",
|
||||
"description": "Apps kunnen ook gestopt worden in plaats van de-installeren om server capaciteit vrij te maken. Toekomstige app backups bevatten geen wijzigingen tussen nu en de laatste app backup. Start daarom handmatig een backup alvorens de app te stoppen."
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "De-installeer",
|
||||
"uninstallAction": "De-installeer",
|
||||
@@ -1023,6 +1041,16 @@
|
||||
"forumAction": "Forum",
|
||||
"appLink": {
|
||||
"title": "Externe Link"
|
||||
},
|
||||
"start": {
|
||||
"title": "Start",
|
||||
"description": "Start de app om deze weer beschikbaar te maken.",
|
||||
"action": "Start"
|
||||
},
|
||||
"stop": {
|
||||
"action": "Stop",
|
||||
"title": "Stop",
|
||||
"description": "Stop de app om bronnen te besparen. Maak vóór het stoppen een back-up om recente wijzigingen te behouden."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -1115,16 +1143,12 @@
|
||||
"stopUpdateAction": "Stop update",
|
||||
"description": "Platform en app updates worden toegepast met de geconfigureerde planning met deze <a href=\"/#/system-locale\">Systeem tijdzone</a>.",
|
||||
"disabled": "Uitgeschakeld",
|
||||
"schedule": "Update planning",
|
||||
"onLatest": "Laatste"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"disableCheckbox": "Automatische updates uitschakelen",
|
||||
"enableCheckbox": "Automatische updates inschakelen",
|
||||
"selectOne": "Selecteer minstens één dag en tijd",
|
||||
"days": "Dagen",
|
||||
"hours": "Uren",
|
||||
"title": "Automatische Update Planning configureren",
|
||||
"description": "Stel de dagen en uren in voor automatische updates van het platform en apps. Zorg ervoor dat dit schema niet overlapt met de back-upschema's."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -1207,7 +1231,12 @@
|
||||
"settingsDialog": {
|
||||
"description": "Een e-mail wordt verstuurd voor de geselecteerde gebeurtenissen naar je primaire e-mail."
|
||||
},
|
||||
"allCaughtUp": "Alles bijgewerkt"
|
||||
"allCaughtUp": "Alles bijgewerkt",
|
||||
"title": "Notificaties",
|
||||
"showAll": "Alles",
|
||||
"showUnread": "Ongelezen",
|
||||
"markUnread": "Markeer als ongelezen",
|
||||
"markRead": "Markeer als gelezen"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logbestanden",
|
||||
@@ -1231,11 +1260,11 @@
|
||||
"reallyDelete": "Wil je het echt verwijderen?"
|
||||
},
|
||||
"newDirectoryDialog": {
|
||||
"title": "Nieuwe mapnaam",
|
||||
"title": "Nieuwe map",
|
||||
"create": "Aanmaken"
|
||||
},
|
||||
"newFileDialog": {
|
||||
"title": "Nieuw bestandsnaam",
|
||||
"title": "Nieuwe bestandsnaam",
|
||||
"create": "Aanmaken"
|
||||
},
|
||||
"renameDialog": {
|
||||
@@ -1253,15 +1282,16 @@
|
||||
"newFolder": "Nieuwe map",
|
||||
"uploadFolder": "Upload map",
|
||||
"openTerminal": "Open terminal",
|
||||
"openLogs": "Open logbestanden"
|
||||
"openLogs": "Open logbestanden",
|
||||
"refresh": "Ververs"
|
||||
},
|
||||
"extractionInProgress": "Bezig met uitpakken",
|
||||
"pasteInProgress": "Bezig met plakken",
|
||||
"deleteInProgress": "Bezig met verwijderen",
|
||||
"chownDialog": {
|
||||
"title": "Eigenaarschap veranderen",
|
||||
"title": "Eigenaar veranderen",
|
||||
"newOwner": "Nieuwe eigenaar",
|
||||
"change": "Eigenaar aanpassen",
|
||||
"change": "Eigenaar veranderen",
|
||||
"recursiveCheckbox": "Eigenaar recursief aanpassen"
|
||||
},
|
||||
"uploadingDialog": {
|
||||
@@ -1482,7 +1512,8 @@
|
||||
"errorIncorrectCredentials": "Onjuiste gebruikersnaam of wachtwoord",
|
||||
"errorIncorrect2FAToken": "2FA token is niet geldig",
|
||||
"errorInternal": "Interne fout, probeer later opnieuw",
|
||||
"loginAction": "Inloggen"
|
||||
"loginAction": "Inloggen",
|
||||
"usePasskeyAction": "Gebruik een passkey"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Wachtwoord herstellen",
|
||||
@@ -1639,7 +1670,8 @@
|
||||
"title": "Backup Locaties",
|
||||
"emptyPlaceholder": "Geen backup locaties",
|
||||
"lastRun": "Laatste uitvoering",
|
||||
"description": "Backuplocaties geven aan waar systeem- en app-backups worden opgeslagen. App-backups kunnen afzonderlijk worden hersteld."
|
||||
"description": "Backuplocaties geven aan waar systeem- en app-backups worden opgeslagen. App-backups kunnen afzonderlijk worden hersteld.",
|
||||
"noAutomaticUpdateBackupWarning": "Er is geen back-uplocatie geconfigureerd om back-ups op te slaan voor automatische updates. Schakel \"Hier automatische back-ups opslaan\" in op minstens één back-uplocatie om automatische updates mogelijk te maken."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
@@ -1678,5 +1710,9 @@
|
||||
},
|
||||
"server": {
|
||||
"title": "Server"
|
||||
},
|
||||
"communityapp": {
|
||||
"installwarning": "Community-apps worden niet door Cloudron beoordeeld. Installeer alleen apps van betrouwbare ontwikkelaars. Code van derden kan uw systeem in gevaar brengen.",
|
||||
"unstablewarning": "Deze app is door de ontwikkelaar gemarkeerd als onstabiel."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,8 +180,6 @@
|
||||
"description": "As palavras-passe da aplicação são uma medida de segurança para proteger a sua conta de utilizador Cloudron. Se precisar de aceder a uma aplicação Cloudron a partir de uma aplicação móvel ou cliente não fidedigno, pode iniciar a sessão com o seu nome de utilizador e a palavra-passe alternativa gerada aqui."
|
||||
},
|
||||
"changePasswordAction": "Alterar palavra-passe",
|
||||
"disable2FAAction": "Desativar 2FA",
|
||||
"enable2FAAction": "Ativar 2FA",
|
||||
"removeAppPassword": {
|
||||
"title": "Remover Palavra-passe da Aplicação",
|
||||
"description": "Remover a palavra-passe da aplicação \"{{ name }}\"?"
|
||||
@@ -619,7 +617,6 @@
|
||||
},
|
||||
"updates": {
|
||||
"checkForUpdatesAction": "Procurar por Atualizações",
|
||||
"schedule": "Agendar",
|
||||
"updateAvailableAction": "Disponível Atualização",
|
||||
"stopUpdateAction": "Parar Atualização",
|
||||
"disabled": "Desativada"
|
||||
@@ -633,8 +630,6 @@
|
||||
"blockingAppsInfo": "Por favor, aguarde que as operações em cima terminem."
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"days": "Dias",
|
||||
"hours": "Horas",
|
||||
"disableCheckbox": "Desativar Atualizações Automáticas",
|
||||
"enableCheckbox": "Ativar Atualizações Automáticas",
|
||||
"selectOne": "Selecione pelo menos um dia e hora"
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"displayName": "Отображаемое имя",
|
||||
"actions": "Действия",
|
||||
"table": {
|
||||
"version": "Версия"
|
||||
"version": "Версия",
|
||||
"created": "Создано"
|
||||
},
|
||||
"action": {
|
||||
"reboot": "Перезагрузка",
|
||||
@@ -51,7 +52,8 @@
|
||||
"next": "Следующий",
|
||||
"configure": "Настроить",
|
||||
"restart": "Перезапуск",
|
||||
"reset": "Сброс"
|
||||
"reset": "Сброс",
|
||||
"loadMore": "Загрузить ещё"
|
||||
},
|
||||
"searchPlaceholder": "Поиск",
|
||||
"multiselect": {
|
||||
@@ -103,6 +105,9 @@
|
||||
"appNotFoundDialog": {
|
||||
"title": "Приложение не найдено",
|
||||
"description": "Не найдено приложения <b>{{ appId }}</b> версии <b>{{ version }}</b>."
|
||||
},
|
||||
"action": {
|
||||
"addCustomApp": "Добавить стороннее приложение"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
@@ -278,18 +283,23 @@
|
||||
"disable": "Отключить"
|
||||
},
|
||||
"enable2FA": {
|
||||
"authenticatorAppDescription": "Используйте Google Authenticator<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>,<a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>,<a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) или аналогичные TOTP приложения для сканирования секретного кода.",
|
||||
"authenticatorAppDescription": "Используйте Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>,<a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>,<a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) или аналогичные TOTP приложения для сканирования секретного кода.",
|
||||
"title": "Включить двухфакторную аутентификацию (2FA)",
|
||||
"token": "Токен",
|
||||
"enable": "Включить",
|
||||
"mandatorySetup": "Для доступа к панели управления требуется 2FA. Пожалуйста, закончите настройку, чтобы продолжить."
|
||||
"mandatorySetup": "Для доступа к панели управления требуется 2FA. Пожалуйста, закончите настройку, чтобы продолжить.",
|
||||
"passkeyOption": "Ключ доступа",
|
||||
"totpOption": "TOTP",
|
||||
"registerPasskey": "Настроить ключ доступа",
|
||||
"passkeyDescription": "Браузер предложит вам создать ключ доступа с помощью биометрических данных вашего устройства или менеджера паролей."
|
||||
},
|
||||
"appPasswords": {
|
||||
"description": "Пароли приложений - это мера безопасности, направленная на защиту вашего аккаунта Cloudron от несанкционированного доступа. Если вам необходим доступ к Cloudron с ненадёжного мобильного или десктопного приложения, вы можете войти под своим именем пользователя и использовать с ним специально сгенерированный пароль.",
|
||||
"title": "Пароли приложений",
|
||||
"app": "Приложение",
|
||||
"name": "Имя",
|
||||
"noPasswordsPlaceholder": "Пароли приложений отсутствуют"
|
||||
"noPasswordsPlaceholder": "Пароли приложений отсутствуют",
|
||||
"expires": "Истекает"
|
||||
},
|
||||
"title": "Профиль",
|
||||
"primaryEmail": "Основной Email",
|
||||
@@ -326,7 +336,8 @@
|
||||
"name": "Имя пароля",
|
||||
"app": "Приложение",
|
||||
"description": "Используйте этот пароль для аутентификации в приложении:",
|
||||
"copyNow": "Пожалуйста, скопируйте сгенерированный пароль. Он не будет показан снова из соображений безопасности."
|
||||
"copyNow": "Пожалуйста, скопируйте сгенерированный пароль. Он не будет показан снова из соображений безопасности.",
|
||||
"expiresAt": "Истекает в"
|
||||
},
|
||||
"createApiToken": {
|
||||
"copyNow": "Пожалуйста, скопируйте сгенерированный API Токен. Он не будет показан снова из соображений безопасности.",
|
||||
@@ -337,8 +348,6 @@
|
||||
"allowedIpRanges": "Разрешённые диапазоны IP"
|
||||
},
|
||||
"changePasswordAction": "Изменить пароль",
|
||||
"disable2FAAction": "Выключить 2FA",
|
||||
"enable2FAAction": "Включить 2FA",
|
||||
"passwordResetNotification": {
|
||||
"body": "Письмо отправлено на адрес электронной почты {{ email }}"
|
||||
},
|
||||
@@ -349,6 +358,11 @@
|
||||
"removeAppPassword": {
|
||||
"title": "Удалить пароль приложения",
|
||||
"description": "Удалить пароль приложения \"{{ name }}\" ?"
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Двухфакторная аутентификация",
|
||||
"totpEnabled": "Используется одноразовый пароль (TOTP)",
|
||||
"passkeyEnabled": "Используется ключ доступа"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
@@ -362,21 +376,22 @@
|
||||
"customAppUpdateInfo": "Для сторонних приложений автообновления недоступны.",
|
||||
"description": "Название & версия приложения",
|
||||
"appId": "ID приложения",
|
||||
"packageVersion": "Версия контейнера",
|
||||
"packageVersion": "Версия пакета",
|
||||
"lastUpdated": "Обновлен",
|
||||
"installedAt": "Установлено"
|
||||
"installedAt": "Установлено",
|
||||
"packager": "Сборщик"
|
||||
},
|
||||
"auto": {
|
||||
"title": "Автоматические обновления",
|
||||
"description": "Обновления приложения применяются периодически, в соответствии с <a href=\"/#/system-update\">расписанием обновлений</a>"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron автоматически проверяет Магазин приложений на наличие обновлений. Вы также можете проверить их вручную."
|
||||
"description": "Cloudron автоматически проверяет наличие обновлений для приложений. Вы также можете проверить их вручную."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
"backups": {
|
||||
"description": "Создать полный снимок приложения.",
|
||||
"description": "Создать полный снимок приложения",
|
||||
"title": "Резервные копии",
|
||||
"downloadConfigTooltip": "Скачать конфигурацию",
|
||||
"cloneTooltip": "Клонировать",
|
||||
@@ -388,7 +403,7 @@
|
||||
},
|
||||
"import": {
|
||||
"title": "Импортировать",
|
||||
"description": "Импортировать приложение из внешней резервной копии."
|
||||
"description": "Импортировать приложение из внешней резервной копии"
|
||||
},
|
||||
"auto": {
|
||||
"title": "Автоматические резервные копии",
|
||||
@@ -416,10 +431,10 @@
|
||||
},
|
||||
"operators": {
|
||||
"title": "Операторы",
|
||||
"description": "Операторы могут настраивать и поддерживать работу этого приложения."
|
||||
"description": "Настроить, кто может поддерживать работу приложения"
|
||||
},
|
||||
"userManagement": {
|
||||
"description": "Настроить, кто может входить и использовать это приложение.",
|
||||
"description": "Настроить, кто может входить и использовать это приложение",
|
||||
"descriptionSftp": "Данный параметр также контролирует доступ к SFTP.",
|
||||
"dashboardVisibility": "Видимость в панели управления",
|
||||
"visibleForAllUsers": "Отображать для всех пользователей Cloudron",
|
||||
@@ -503,7 +518,7 @@
|
||||
"hourly": "Каждый час",
|
||||
"service": "Проверить (один запуск)"
|
||||
},
|
||||
"addCommonPattern": "Добавить общий шаблон",
|
||||
"addCommonPattern": "Вставить общий шаблон",
|
||||
"description": "Задания Cron, требуемые для правильной работы приложения, уже интегрированы в контейнер. Здесь можно настроить прочие задания."
|
||||
},
|
||||
"display": {
|
||||
@@ -553,11 +568,27 @@
|
||||
"csp": {
|
||||
"title": "Политика безопасности контента",
|
||||
"saveAction": "Сохранить",
|
||||
"description": "Перезаписать любые CSP заголовки, отправляемые приложением."
|
||||
"description": "Перезаписать любые CSP заголовки, отправляемые приложением",
|
||||
"insertCommonCsp": "Вставить стандартный CSP",
|
||||
"commonPattern": {
|
||||
"allowEmbedding": "Разрешить встраивание",
|
||||
"sameOriginEmbedding": "Разрешить встраивание (только поддомены)",
|
||||
"allowCdnAssets": "Разрешить использование ресурсов CDN",
|
||||
"reportOnly": "Сообщить о нарушениях CSP",
|
||||
"strictBaseline": "Строгий базовый уровень"
|
||||
}
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"description": "По умолчанию, роботы могут индексировать это приложение."
|
||||
"description": "По умолчанию, роботы могут индексировать это приложение",
|
||||
"commonPattern": {
|
||||
"allowAll": "Разрешить все (по умолчанию)",
|
||||
"disallowAll": "Запретить все",
|
||||
"disallowCommonBots": "Запретить известных ботов",
|
||||
"disallowAdminPaths": "Запретить пути админа",
|
||||
"disallowApiPaths": "Запретить пути API"
|
||||
},
|
||||
"insertCommonRobotsTxt": "Вставить стандартный robots.txt"
|
||||
},
|
||||
"hstsPreload": "Активировать предзагрузку HSTS (в том числе для поддоменов)"
|
||||
},
|
||||
@@ -580,11 +611,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"description": "Вместо удаления, приложение может быть остановлено для освобождения ресурсов сервера. Будущие резервные копии не сохранят текущее состояние приложения до момента остановки. Рекомендуется запустить процесс резервного копирования вручную до остановки работы приложения.",
|
||||
"startAction": "Запустить",
|
||||
"stopAction": "Остановить"
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "Удаление",
|
||||
"description": "Удалить приложение и все его данные. Резервные копии очищаются в соответствии с политикой резервного копирования.",
|
||||
@@ -627,7 +653,7 @@
|
||||
"cloneDialog": {
|
||||
"title": "Клонировать приложение",
|
||||
"location": "Расположение",
|
||||
"description": "Клон использует резервную копию версии <b>v{{ packageVersion }}</b> от <b>{{ creationTime }}</b>."
|
||||
"description": "Клон использует резервную копию версии <b>{{ packageVersion }}</b> от <b>{{ creationTime }}</b>."
|
||||
},
|
||||
"addApplinkDialog": {
|
||||
"title": "Добавить Внешнюю ссылку"
|
||||
@@ -670,6 +696,16 @@
|
||||
"forumAction": "Форум",
|
||||
"appLink": {
|
||||
"title": "Внешняя ссылка"
|
||||
},
|
||||
"start": {
|
||||
"title": "Старт",
|
||||
"description": "Запустить приложение и сделать его снова доступным.",
|
||||
"action": "Старт"
|
||||
},
|
||||
"stop": {
|
||||
"action": "Стоп",
|
||||
"title": "Стоп",
|
||||
"description": "Остановить приложение, чтобы сохранить ресурсы. Создайте резервную копию перед этим, чтобы сохранить последние изменения."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -686,7 +722,8 @@
|
||||
"tooltipDownloadBackupConfig": "Скачать конфигурацию",
|
||||
"cleanupBackups": "Очистить резервные копии",
|
||||
"backupNow": "Создать копию",
|
||||
"tooltipPreservedBackup": "Резервная копия будет сохранена"
|
||||
"tooltipPreservedBackup": "Резервная копия будет сохранена",
|
||||
"description": "Системные резервные копии содержат настройки Cloudron и метаданные приложений. Они могут быть использованы для <a href=\"{{restoreLink}}\" target=\"_blank\">восстановления</a> или <a href=\"{{migrateLink}}\" target=\"_blank\">переноса</a> Cloudron на другой сервер."
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Расписание & политика хранения",
|
||||
@@ -772,11 +809,14 @@
|
||||
"title": "Резервные копии",
|
||||
"backupDetails": {
|
||||
"title": "Детали резервного копирования",
|
||||
"id": "Id",
|
||||
"date": "Дата",
|
||||
"version": "Версия",
|
||||
"id": "ID Резервной копии",
|
||||
"date": "Создано",
|
||||
"version": "Версия пакета",
|
||||
"size": "Размер",
|
||||
"duration": "Продолжительность"
|
||||
"duration": "Продолжительность резервного копирования",
|
||||
"lastIntegrityCheck": "Последняя проверка целостности",
|
||||
"integrityNever": "никогда",
|
||||
"integrityInProgress": "В процессе"
|
||||
},
|
||||
"backupEdit": {
|
||||
"title": "Редактировать резервную копию",
|
||||
@@ -818,7 +858,9 @@
|
||||
"title": "Настроить содержание резервной копии"
|
||||
},
|
||||
"useFileAndFileNameEncryption": "Используется шифрование файлов и их имён",
|
||||
"useFileEncryption": "Используется шифрование файлов"
|
||||
"useFileEncryption": "Используется шифрование файлов",
|
||||
"checkIntegrity": "Проверить целостность",
|
||||
"stopIntegrity": "Остановить проверку целостности"
|
||||
},
|
||||
"branding": {
|
||||
"title": "Брендирование",
|
||||
@@ -1005,17 +1047,13 @@
|
||||
"updateAvailableAction": "Доступно обновление",
|
||||
"stopUpdateAction": "Остановить обновление",
|
||||
"description": "Обновления платформы и приложений запускаются с учётом установленного расписания и в соответствии с <a href=\"/#/system-settings\">системным часовым поясом</a>.",
|
||||
"schedule": "Расписание обновлений",
|
||||
"disabled": "Выключено",
|
||||
"onLatest": "последний"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "Настроить расписание автоматических обновлений",
|
||||
"disableCheckbox": "Выключить автоматические обновления",
|
||||
"enableCheckbox": "Включить автоматические обновления",
|
||||
"selectOne": "Выберите по крайней мере один день и время",
|
||||
"days": "Дни",
|
||||
"hours": "Часы",
|
||||
"description": "Установите дни и часы, в которые будет происходить автоматическое обновление платформы и приложений. Убедитесь, что установленное расписание не пересекается с расписанием резервного копирования."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -1092,7 +1130,9 @@
|
||||
"changeDashboardDomain": {
|
||||
"title": "Домен панели управления",
|
||||
"changeAction": "Изменить домен",
|
||||
"description": "Изменяет поддомен панели управления \"my\" для выбранного домена"
|
||||
"description": "Изменяет поддомен панели управления \"my\" для выбранного домена",
|
||||
"confirmMessage": "Это действие сбросит ключи доступа для всех пользователей.",
|
||||
"confirmTitle": "Вы точно хотите сменить домен панели управления?"
|
||||
},
|
||||
"domainDialog": {
|
||||
"editTitle": "Редактировать домен",
|
||||
@@ -1150,7 +1190,9 @@
|
||||
"inwxUsername": "Имя пользователя INWX",
|
||||
"inwxPassword": "Пароль INWX",
|
||||
"customNameservers": "Домен использует пользовательские серверы имён (vanity)",
|
||||
"zoneNamePlaceholder": "Необязательно. Если не указано, используется корневой домен."
|
||||
"zoneNamePlaceholder": "Необязательно. Если не указано, используется корневой домен.",
|
||||
"carddavLocation": "Расположение сервера CardDAV",
|
||||
"caldavLocation": "Расположение сервера CalDAV"
|
||||
},
|
||||
"removeDialog": {
|
||||
"title": "Удалить домен",
|
||||
@@ -1189,7 +1231,12 @@
|
||||
"allCaughtUp": "Уведомления отсутствуют",
|
||||
"settingsDialog": {
|
||||
"description": "Уведомления о выбранных событиях будут отправлены на основной Email."
|
||||
}
|
||||
},
|
||||
"title": "Уведомления",
|
||||
"showAll": "Все",
|
||||
"showUnread": "Непрочитанные",
|
||||
"markUnread": "Отметить как непрочитанные",
|
||||
"markRead": "Отметить как прочитанные"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Логи",
|
||||
@@ -1210,7 +1257,7 @@
|
||||
"filemanager": {
|
||||
"title": "Файловый менеджер",
|
||||
"newDirectoryDialog": {
|
||||
"title": "Имя новой папки",
|
||||
"title": "Новая папка",
|
||||
"create": "Создать"
|
||||
},
|
||||
"newFileDialog": {
|
||||
@@ -1232,7 +1279,8 @@
|
||||
"restartApp": "Перезагрузить приложение",
|
||||
"uploadFolder": "Загрузить папку",
|
||||
"openTerminal": "Открыть терминал",
|
||||
"openLogs": "Открыть логи"
|
||||
"openLogs": "Открыть логи",
|
||||
"refresh": "Обновить"
|
||||
},
|
||||
"removeDialog": {
|
||||
"reallyDelete": "Действительно удалить?"
|
||||
@@ -1241,7 +1289,7 @@
|
||||
"pasteInProgress": "Выполняется копирование / перемещение",
|
||||
"deleteInProgress": "Выполняется удаление",
|
||||
"chownDialog": {
|
||||
"title": "Смена владельца",
|
||||
"title": "Изменить владельца",
|
||||
"newOwner": "Новый владелец",
|
||||
"change": "Изменить владельца",
|
||||
"recursiveCheckbox": "Изменить владельца рекурсивно"
|
||||
@@ -1272,7 +1320,7 @@
|
||||
"symlink": "Символическая ссылка на {{ target }}",
|
||||
"menu": {
|
||||
"rename": "Переименовать",
|
||||
"chown": "Изменить владельца",
|
||||
"chown": "Смена владельца",
|
||||
"extract": "Распаковать здесь",
|
||||
"download": "Скачать",
|
||||
"delete": "Удалить",
|
||||
@@ -1464,7 +1512,8 @@
|
||||
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль",
|
||||
"errorIncorrect2FAToken": "Неверный 2FA токен",
|
||||
"errorInternal": "Внутренняя ошибка, попробуйте позже",
|
||||
"loginAction": "Войти"
|
||||
"loginAction": "Войти",
|
||||
"usePasskeyAction": "Использовать ключ доступа"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Сброс пароля",
|
||||
@@ -1608,7 +1657,8 @@
|
||||
"archives": {
|
||||
"listing": {
|
||||
"placeholder": "Архивные приложения отсутствуют"
|
||||
}
|
||||
},
|
||||
"description": "В архивированном приложении сохраняется его последняя резервная копия. Эта копия хранится постоянно и может быть восстановлена в любой момент."
|
||||
},
|
||||
"backup": {
|
||||
"target": {
|
||||
@@ -1619,7 +1669,9 @@
|
||||
"sites": {
|
||||
"title": "Локации резервных копий",
|
||||
"emptyPlaceholder": "Локации отсутствуют",
|
||||
"lastRun": "Последний запуск"
|
||||
"lastRun": "Последний запуск",
|
||||
"description": "Локации резервных копий указывают на то, где будут сохраняться копии системы и приложений. Резервные копии приложений могут быть восстановлены по-отдельности.",
|
||||
"noAutomaticUpdateBackupWarning": "Не настроено ни одной локации резервных копий для хранения копий автоматических обновлений. Включите \"Хранить бэкапы автоматических обновлений здесь\" по крайней мере в одной локации, чтобы активировать автоматические обновления."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
@@ -1658,5 +1710,9 @@
|
||||
},
|
||||
"server": {
|
||||
"title": "Сервер"
|
||||
},
|
||||
"communityapp": {
|
||||
"installwarning": "Cloudron не проводит аудит приложений, созданных сообществом. Устанавливайте приложения только от проверенных разработчиков. Сторонний код может поставить под угрозу безопасности вашей системы.",
|
||||
"unstablewarning": "Разработчик пометил это приложение как нестабильное."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +291,6 @@
|
||||
"access": "Truy cập API",
|
||||
"allowedIpRanges": "Dãy IP cho phép"
|
||||
},
|
||||
"enable2FAAction": "Bật xác minh hai bước",
|
||||
"primaryEmail": "Email chính",
|
||||
"passwordRecoveryEmail": "Email khôi phục mật khẩu",
|
||||
"appPasswords": {
|
||||
@@ -324,7 +323,6 @@
|
||||
"email": "Thêm email mới",
|
||||
"password": "Xác nhận bằng mật khẩu"
|
||||
},
|
||||
"disable2FAAction": "Tắt xác minh hai bước",
|
||||
"changeFallbackEmail": {
|
||||
"title": "Đổi email khôi phục mật khẩu"
|
||||
},
|
||||
@@ -806,12 +804,9 @@
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"description": "Chọn ngày và thời gian mà Cloudron sẽ tự động cập nhật phiên bản mới của hệ thống và app. Xin tránh chọn trùng lịch cập nhật này với <a href=\"/#/backups\">lịch sao lưu</a>.",
|
||||
"hours": "Thời gian",
|
||||
"selectOne": "Xin chọn ít nhất một ngày và thời gian",
|
||||
"days": "Ngày",
|
||||
"enableCheckbox": "Bật chế độ cập nhật tự động",
|
||||
"disableCheckbox": "Tắt chế độ cập nhật tự động",
|
||||
"title": "Cấu hình lịch cập nhật tự động"
|
||||
"disableCheckbox": "Tắt chế độ cập nhật tự động"
|
||||
},
|
||||
"updates": {
|
||||
"checkForUpdatesAction": "Kiểm tra cập nhật",
|
||||
@@ -819,7 +814,6 @@
|
||||
"updateAvailableAction": "Có phiên bản cập nhật mới",
|
||||
"title": "Cập nhật",
|
||||
"disabled": "Đã tắt",
|
||||
"schedule": "Lịch cập nhật",
|
||||
"description": "Cập nhật Hệ thống và Ứng dụng được thực hiện tự động dựa trên Lịch cập nhật trong <a href=\"/#/settings\">Múi giờ hệ thống</a>."
|
||||
},
|
||||
"timezone": {
|
||||
@@ -1078,11 +1072,6 @@
|
||||
"uninstallAction": "Xoá",
|
||||
"description": "Việc này sẽ gỡ cài đặt app và xóa tất cả dữ liệu trong app. Các bản sao lưu sẽ được dọn dẹp dựa trên chính sách sao lưu.",
|
||||
"title": "Xoá"
|
||||
},
|
||||
"startStop": {
|
||||
"stopAction": "Dừng",
|
||||
"startAction": "Khởi động",
|
||||
"description": "App có thể được dừng chạy để bảo tồn tài nguyên server thay vì xoá app. Những bản sao lưu tương lai sẽ không bao gồm những thay đổi từ thời điểm này đến bản sao lưu kề cận nhất. Vì lý do này, bạn nên tạo một bản sao lưu trước khi cho dừng app."
|
||||
}
|
||||
},
|
||||
"repair": {
|
||||
|
||||
@@ -37,8 +37,6 @@
|
||||
"copyNow": "请复制 API Token。出于安全考虑,这个 API Token 未来不会再显示。"
|
||||
},
|
||||
"changePasswordAction": "修改密码",
|
||||
"disable2FAAction": "停用双因素验证",
|
||||
"enable2FAAction": "启用双因素验证",
|
||||
"title": "个人资料",
|
||||
"primaryEmail": "主要 Email",
|
||||
"passwordRecoveryEmail": "密码恢复 Email",
|
||||
@@ -534,12 +532,9 @@
|
||||
"stopUpdateAction": "停止更新"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "配置自动更新时间表",
|
||||
"disableCheckbox": "停用自动更新",
|
||||
"enableCheckbox": "启用自动更新",
|
||||
"selectOne": "选择至少一个日期和时间",
|
||||
"days": "星期",
|
||||
"hours": "小时",
|
||||
"description": "选择检查平台和应用更新的日子和时间。请注意这个时间不要和 <a href=\"/#/backups\">备份时间</a> 冲突。"
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -1034,11 +1029,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"startAction": "启动应用",
|
||||
"description": "可以通过停止应用(而非卸载)来节省服务器资源。停用后的自动备份不会包括当前的状态,有鉴于此,建议你在停止应用之前进行一次手动备份。",
|
||||
"stopAction": "停止应用"
|
||||
},
|
||||
"uninstall": {
|
||||
"description": "将会卸载此应用,并删除所有数据。卸载后该应用将不可用。",
|
||||
"title": "卸载",
|
||||
|
||||
+34
-9
@@ -5,12 +5,13 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, provide } from 'vue';
|
||||
import { Notification, fetcher } from '@cloudron/pankow';
|
||||
import { Notification, InputDialog, fetcher } from '@cloudron/pankow';
|
||||
import { setLanguage } from './i18n.js';
|
||||
import { API_ORIGIN, TOKEN_TYPES } from './constants.js';
|
||||
import { redirectIfNeeded } from './utils.js';
|
||||
import { redirectIfNeeded, startAuthFlow } from './utils.js';
|
||||
import ProfileModel from './models/ProfileModel.js';
|
||||
import ProvisionModel from './models/ProvisionModel.js';
|
||||
import NotificationsModel from './models/NotificationsModel.js';
|
||||
import DashboardModel from './models/DashboardModel.js';
|
||||
import BrandingModel from './models/BrandingModel.js';
|
||||
import Headerbar from './components/Headerbar.vue';
|
||||
@@ -34,6 +35,7 @@ import EmailSettingsView from './views/EmailSettingsView.vue';
|
||||
import EmailEventlogView from './views/EmailEventlogView.vue';
|
||||
import EventlogView from './views/EventlogView.vue';
|
||||
import NetworkView from './views/NetworkView.vue';
|
||||
import NotificationsView from './views/NotificationsView.vue';
|
||||
import ProfileView from './views/ProfileView.vue';
|
||||
import ServicesView from './views/ServicesView.vue';
|
||||
import SystemSettingsView from './views/SystemSettingsView.vue';
|
||||
@@ -64,6 +66,7 @@ const VIEWS = Object.freeze({
|
||||
EMAIL_EVENTLOG: '#/email-eventlog',
|
||||
SERVER: '#/server',
|
||||
NETWORK: '#/network',
|
||||
NOTIFICATIONS: '#/notifications',
|
||||
PROFILE: '#/profile',
|
||||
SERVICES: '#/services',
|
||||
SYSTEM_SETTINGS: '#/system-settings',
|
||||
@@ -273,12 +276,15 @@ fetcher.globalOptions.errorHook = (error) => {
|
||||
const dashboardModel = DashboardModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
const provisionModel = ProvisionModel.create();
|
||||
const notificationModel = NotificationsModel.create();
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
const subscriptionRequiredDialog = useTemplateRef('subscriptionRequiredDialog');
|
||||
const ready = ref(false);
|
||||
const view = ref('');
|
||||
const profile = ref({});
|
||||
const dashboardDomain = ref('');
|
||||
const notificationCount = ref(0);
|
||||
const subscription = ref({
|
||||
plan: {},
|
||||
});
|
||||
@@ -319,6 +325,8 @@ function onHashChange() {
|
||||
view.value = VIEWS.EMAIL_EVENTLOG;
|
||||
} else if (v === VIEWS.SERVER && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.SERVER;
|
||||
} else if (v === VIEWS.NOTIFICATIONS && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.NOTIFICATIONS;
|
||||
} else if (v === VIEWS.NETWORK && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.NETWORK;
|
||||
} else if (v === VIEWS.PROFILE) {
|
||||
@@ -381,6 +389,9 @@ async function refreshConfigAndFeatures() {
|
||||
console.log('Dashboard version changed, reloading');
|
||||
localStorage.setItem('version', result.version);
|
||||
window.location.reload(true);
|
||||
|
||||
// return never ending promise to just wait for the reload
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
config.value = result;
|
||||
@@ -388,6 +399,12 @@ async function refreshConfigAndFeatures() {
|
||||
dashboardDomain.value = result.adminDomain;
|
||||
}
|
||||
|
||||
async function refreshNotifications() {
|
||||
const [error, result] = await notificationModel.list(false);
|
||||
if (error) return console.error(error);
|
||||
notificationCount.value = result.length;
|
||||
}
|
||||
|
||||
async function onOnline() {
|
||||
ready.value = true;
|
||||
await refreshConfigAndFeatures(); // reload dashboard if needed after an update
|
||||
@@ -403,8 +420,10 @@ provide('features', features);
|
||||
provide('profile', profile);
|
||||
provide('refreshProfile', refreshProfile);
|
||||
provide('refreshFeatures', refreshConfigAndFeatures);
|
||||
provide('refreshNotifications', refreshNotifications);
|
||||
provide('dashboardDomain', dashboardDomain);
|
||||
provide('isMobile', isMobile);
|
||||
provide('inputDialog', inputDialog);
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', checkForMobile);
|
||||
@@ -417,29 +436,33 @@ onMounted(async () => {
|
||||
if (!localStorage.token) {
|
||||
localStorage.setItem('redirectToHash', window.location.hash);
|
||||
|
||||
// start oidc flow
|
||||
window.location.href = `${API_ORIGIN}/openid/auth?client_id=` + (API_ORIGIN ? TOKEN_TYPES.ID_DEVELOPMENT : TOKEN_TYPES.ID_WEBADMIN) + '&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
|
||||
const clientId = API_ORIGIN ? TOKEN_TYPES.ID_DEVELOPMENT : TOKEN_TYPES.ID_WEBADMIN;
|
||||
window.location.href = await startAuthFlow(clientId, API_ORIGIN);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshConfigAndFeatures();
|
||||
await refreshProfile();
|
||||
|
||||
// ensure language from profile if set
|
||||
if (profile.value.language) await setLanguage(profile.value.language, true);
|
||||
|
||||
await refreshConfigAndFeatures();
|
||||
|
||||
avatarUrl.value = `https://${config.value.adminFqdn}/api/v1/cloudron/avatar`;
|
||||
|
||||
if (config.value.mandatory2FA && !profile.value.twoFactorAuthenticationEnabled) window.location.href = VIEWS.PROFILE;
|
||||
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
onHashChange();
|
||||
|
||||
console.log(`Cloudron dashboard v${config.value.version}`);
|
||||
|
||||
if (profile.value.isAtLeastAdmin) refreshNotifications();
|
||||
|
||||
ready.value = true;
|
||||
|
||||
// when done, redirect the user to setup 2fa if it is mandatory and neither totp nor passkey is setup
|
||||
if (config.value.mandatory2FA && !profile.value.totpEnabled && !profile.value.hasPasskey) {
|
||||
return window.location.href = VIEWS.PROFILE;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -454,12 +477,13 @@ onUnmounted(() => {
|
||||
<OfflineOverlay ref="offlineOverlay" @online="onOnline()" :href="'https://docs.cloudron.io/troubleshooting/'" :label="$t('main.offline')" />
|
||||
<SubscriptionRequiredDialog ref="subscriptionRequiredDialog"/>
|
||||
<RequestErrorDialog/>
|
||||
<InputDialog ref="inputDialog"/>
|
||||
|
||||
<div v-if="ready" style="display: flex; flex-direction: row; overflow: hidden; height: 100%;">
|
||||
<SideBar v-if="profile.isAtLeastUserManager" :items="menuItems" :cloudron-name="config.cloudronName" :cloudron-avatar-url="avatarUrl"/>
|
||||
|
||||
<div style="flex-grow: 1; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
||||
<Headerbar :config="config" :subscription="subscription"/>
|
||||
<Headerbar :config="config" :subscription="subscription" :notification-count="notificationCount"/>
|
||||
|
||||
<div style="display: flex; justify-content: center; overflow: auto; flex-grow: 1; padding: 0; margin: 0 10px; position: relative;">
|
||||
<KeepAlive>
|
||||
@@ -481,6 +505,7 @@ onUnmounted(() => {
|
||||
<EventlogView v-else-if="view === VIEWS.SYSTEM_EVENTLOG" />
|
||||
<ServerView v-else-if="view === VIEWS.SERVER" />
|
||||
<NetworkView v-else-if="view === VIEWS.NETWORK" />
|
||||
<NotificationsView v-else-if="view === VIEWS.NOTIFICATIONS" />
|
||||
<ProfileView v-else-if="view === VIEWS.PROFILE" />
|
||||
<ServicesView v-else-if="view === VIEWS.SERVICES" />
|
||||
<SystemSettingsView v-else-if="view === VIEWS.SYSTEM_SETTINGS" />
|
||||
|
||||
@@ -43,7 +43,7 @@ const cloudronAuth = computed(() => {
|
||||
<template>
|
||||
<div>
|
||||
<FormGroup>
|
||||
<label>{{ $t('appstore.installDialog.userManagement') }} <sup v-if="!manifest.addons.email"><a href="https://docs.cloudron.io/apps/#access-restriction" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<label>{{ $t('appstore.installDialog.userManagement') }} <sup v-if="!manifest.addons.email"><a href="https://docs.cloudron.io/apps/#access-control" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div v-if="cloudronAuth && !manifest.addons.email">{{ $t('app.accessControl.userManagement.description') }}</div>
|
||||
<div v-if="!cloudronAuth && !manifest.addons.email">{{ $t('appstore.installDialog.userManagementNone') }}</div>
|
||||
<div v-if="manifest.addons.email" v-html="$t('appstore.installDialog.userManagementMailbox')"></div>
|
||||
@@ -52,7 +52,7 @@ const cloudronAuth = computed(() => {
|
||||
<div style="padding-top: 10px" v-if="!cloudronAuth || manifest.addons.email"/>
|
||||
|
||||
<FormGroup>
|
||||
<label v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#dashboard-visibility" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<label v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#no-sso" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.dashboardVisibility.description') }}</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -66,7 +66,7 @@ const cloudronAuth = computed(() => {
|
||||
|
||||
<div v-if="accessRestrictionOption === ACL_OPTIONS.RESTRICTED" style="margin-top: 12px; margin-left: 20px; display: flex; gap: 10px;">
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="username" :search-threshold="20" />
|
||||
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="label" :search-threshold="20" />
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />
|
||||
|
||||
@@ -11,6 +11,8 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const quickActions = computed(() => {
|
||||
if (window.innerWidth <= 576) return [];
|
||||
|
||||
const visibleActions = props.actions.filter(a => !(typeof a.visible !== 'undefined' && !a.visible) && !a.separator);
|
||||
if (visibleActions.length <= 2) return visibleActions;
|
||||
|
||||
@@ -35,7 +37,7 @@ function onMenu(event) {
|
||||
<div class="action-bar" :class="{ 'is-menu-open': isMenuOpen }">
|
||||
<Menu ref="menuElement" :model="actions" @close="isMenuOpen = false" />
|
||||
<ButtonGroup class="quick-action-group">
|
||||
<Button tool v-for="quickAction in quickActions" :key="quickAction" :icon="quickAction.icon" @click="quickAction.action()" :href="quickAction.href || null" :target="quickAction.target || null" v-tooltip.top="quickAction.label"/>
|
||||
<Button tool v-for="quickAction in quickActions" :key="quickAction" :icon="quickAction.icon" @click="quickAction.action && quickAction.action()" :href="quickAction.href || null" :target="quickAction.target || null" v-tooltip.top="quickAction.label"/>
|
||||
<Button tool @click.capture="onMenu($event)" icon="fa-solid fa-ellipsis" v-if="visibleActionCount > 0 && visibleActionCount !== quickActions.length"/>
|
||||
</ButtonGroup>
|
||||
<Button tool :plain="isMenuOpen ? null : true" secondary @click.capture="onMenu($event)" icon="fa-solid fa-ellipsis" v-if="visibleActionCount > 0" class="menu-action" :class="{ 'hide-on-touch': visibleActionCount === quickActions.length }"/>
|
||||
|
||||
@@ -185,19 +185,19 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="apiTokens" :placeholder="$t('profile.apiTokens.noTokensPlaceholder')">
|
||||
<template #lastUsedTime="apiToken">
|
||||
<template #lastUsedTime="{ item:apiToken }">
|
||||
<span v-if="apiToken.lastUsedTime">{{ prettyLongDate(apiToken.lastUsedTime) }}</span>
|
||||
<span v-else>{{ $t('profile.apiTokens.neverUsed') }}</span>
|
||||
</template>
|
||||
<template #scope="apiToken">
|
||||
<template #scope="{ item:apiToken }">
|
||||
<span v-if="apiToken.scope['*'] === 'rw'">{{ $t('profile.apiTokens.readwrite') }}</span>
|
||||
<span v-else>{{ $t('profile.apiTokens.readonly') }}</span>
|
||||
</template>
|
||||
<template #allowedIpRanges="apiToken">
|
||||
<template #allowedIpRanges="{ item:apiToken }">
|
||||
<span v-if="apiToken.allowedIpRanges !== ''">{{ apiToken.allowedIpRanges }}</span>
|
||||
<span v-else>{{ '*' }}</span>
|
||||
</template>
|
||||
<template #actions="apiToken">
|
||||
<template #actions="{ item:apiToken }">
|
||||
<ActionBar :actions="createActionMenu(apiToken)" />
|
||||
</template>
|
||||
</TableView>
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, computed, useTemplateRef, onMounted, inject } from 'vue';
|
||||
import { ref, computed, useTemplateRef, onMounted, inject, watch } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, InputGroup, Spinner } from '@cloudron/pankow';
|
||||
import { prettyDate, prettyBinarySize, isValidDomain } from '@cloudron/pankow/utils';
|
||||
import { prettyDate, prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import AccessControl from './AccessControl.vue';
|
||||
import PortBindings from './PortBindings.vue';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import AppstoreModel from '../models/AppstoreModel.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
import GroupsModel from '../models/GroupsModel.js';
|
||||
import { PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
|
||||
import { API_ORIGIN, PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
|
||||
|
||||
const STEP = Object.freeze({
|
||||
LOADING: Symbol('loading'),
|
||||
@@ -19,7 +18,6 @@ const STEP = Object.freeze({
|
||||
INSTALL: Symbol('install'),
|
||||
});
|
||||
|
||||
const appstoreModel = AppstoreModel.create();
|
||||
const appsModel = AppsModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
const usersModel = UsersModel.create();
|
||||
@@ -31,7 +29,10 @@ const dashboardDomain = inject('dashboardDomain');
|
||||
// reactive
|
||||
const busy = ref(false);
|
||||
const formError = ref({});
|
||||
const app = ref({});
|
||||
|
||||
// community { iconUrl, versionsUrl, manifest, publishState, creationDate, ts }
|
||||
// appstore { id, iconUrl, appStoreId, manifest, creationDate, publishState }
|
||||
const packageData = ref({});
|
||||
const manifest = ref({});
|
||||
const step = ref(STEP.DETAILS);
|
||||
const dialog = useTemplateRef('dialogHandle');
|
||||
@@ -39,23 +40,28 @@ const locationInput = useTemplateRef('locationInput');
|
||||
const description = computed(() => marked.parse(manifest.value.description || ''));
|
||||
const domains = ref([]);
|
||||
|
||||
const formValid = computed(() => {
|
||||
if (!domain.value) return false;
|
||||
const form = ref(null); // assigned via "Function Ref" because it is inside v-if
|
||||
const isFormValid = ref(false);
|
||||
async function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (location.value && !isValidDomain(location.value + '.' + domain.value)) return false;
|
||||
if (isFormValid.value) {
|
||||
if (accessRestrictionOption.value === ACL_OPTIONS.RESTRICTED && (accessRestrictionAcl.value.users.length === 0 && accessRestrictionAcl.value.groups.length === 0)) {
|
||||
isFormValid.value = true;
|
||||
}
|
||||
|
||||
if (accessRestrictionOption.value === ACL_OPTIONS.RESTRICTED && (accessRestrictionAcl.value.users.length === 0 && accessRestrictionAcl.value.groups.length === 0)) return false;
|
||||
|
||||
if (manifest.value.id === PROXY_APP_ID) {
|
||||
try {
|
||||
new URL(upstreamUri.value);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
return false;
|
||||
if (manifest.value.id === PROXY_APP_ID) {
|
||||
try {
|
||||
new URL(upstreamUri.value);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
watch(form, () => { // trigger form validation when the ref becomes set
|
||||
setTimeout(checkValidity, 100);
|
||||
});
|
||||
|
||||
const appMaxCountExceeded = ref(false);
|
||||
@@ -68,7 +74,9 @@ function setStep(newStep) {
|
||||
}
|
||||
|
||||
step.value = newStep;
|
||||
if (newStep === STEP.INSTALL) setTimeout(() => locationInput.value.$el.focus(), 500);
|
||||
if (newStep === STEP.INSTALL) {
|
||||
setTimeout(() => locationInput.value.$el.focus(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
// form data
|
||||
@@ -91,6 +99,8 @@ function onDomainChange() {
|
||||
}
|
||||
|
||||
async function onSubmit(overwriteDns) {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
formError.value = {};
|
||||
busy.value = true;
|
||||
|
||||
@@ -148,12 +158,12 @@ async function onSubmit(overwriteDns) {
|
||||
|
||||
if (manifest.value.id === PROXY_APP_ID) config.upstreamUri = upstreamUri.value;
|
||||
|
||||
const [error, result] = await appsModel.install(manifest.value, config);
|
||||
const [error, result] = await appsModel.install(packageData.value, config);
|
||||
|
||||
if (!error) {
|
||||
dialog.value.close();
|
||||
localStorage['confirmPostInstall_' + result.id] = true;
|
||||
return window.location.href = '/#/apps';
|
||||
if (manifest.value.postInstallMessage) localStorage['confirmPostInstall_' + result.id] = true;
|
||||
return window.location.href = `/#/app/${result.id}/info`;
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
@@ -175,7 +185,7 @@ function onClose() {
|
||||
onMounted(async () => {
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.username = u.username || u.email);
|
||||
result.forEach(u => { u.label = u.displayName || u.username || u.email });
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
@@ -201,35 +211,15 @@ function onScreenshotNext() {
|
||||
elem.scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' });
|
||||
}
|
||||
|
||||
async function getApp(id, version = '') {
|
||||
const [error, result] = await appstoreModel.get(id, version);
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: async function(appId, version, appCountExceeded, domainList) {
|
||||
open: async function(pd, appCountExceeded, domainList) {
|
||||
busy.value = false;
|
||||
step.value = STEP.LOADING;
|
||||
formError.value = {};
|
||||
|
||||
// give it some time to fetch before showing loading
|
||||
const openTimer = setTimeout(dialog.value.open, 200);
|
||||
|
||||
const a = await getApp(appId, version);
|
||||
if (!a) {
|
||||
clearTimeout(openTimer);
|
||||
dialog.value.close();
|
||||
throw new Error('app not found');
|
||||
}
|
||||
|
||||
app.value = a;
|
||||
packageData.value = pd;
|
||||
appMaxCountExceeded.value = appCountExceeded;
|
||||
manifest.value = a.manifest;
|
||||
manifest.value = packageData.value.manifest;
|
||||
location.value = '';
|
||||
accessRestrictionOption.value = ACL_OPTIONS.ANY;
|
||||
accessRestrictionAcl.value = { users: [], groups: [] };
|
||||
@@ -246,8 +236,8 @@ defineExpose({
|
||||
// preselect with dashboard domain
|
||||
domain.value = domains.value.find(d => d.domain === dashboardDomain.value).domain;
|
||||
|
||||
tcpPorts.value = a.manifest.tcpPorts;
|
||||
udpPorts.value = a.manifest.udpPorts;
|
||||
tcpPorts.value = manifest.value.tcpPorts;
|
||||
udpPorts.value = manifest.value.udpPorts;
|
||||
|
||||
// ensure we have value property
|
||||
for (const p in tcpPorts.value) {
|
||||
@@ -259,7 +249,7 @@ defineExpose({
|
||||
udpPorts.value[p].enabled = udpPorts.value[p].enabledByDefault ?? true;
|
||||
}
|
||||
|
||||
secondaryDomains.value = a.manifest.httpPorts;
|
||||
secondaryDomains.value = manifest.value.httpPorts;
|
||||
for (const p in secondaryDomains.value) {
|
||||
const port = secondaryDomains.value[p];
|
||||
port.value = port.defaultValue;
|
||||
@@ -268,6 +258,7 @@ defineExpose({
|
||||
|
||||
currentScreenshotPos = 0;
|
||||
step.value = STEP.DETAILS;
|
||||
dialog.value.open();
|
||||
},
|
||||
close() {
|
||||
dialog.value.close();
|
||||
@@ -283,15 +274,14 @@ defineExpose({
|
||||
</div>
|
||||
<div v-else class="app-install-dialog-body" :class="{ 'step-detail': step === STEP.DETAILS, 'step-install': step === STEP.INSTALL }">
|
||||
<div class="app-install-header">
|
||||
<div class="summary" v-if="app.manifest">
|
||||
<div class="title"><img class="icon pankow-no-desktop" style="width: 32px; height: 32px; margin-right: 10px" :src="app.iconUrl" />{{ manifest.title }}</div>
|
||||
<div>{{ $t('app.updates.info.packageVersion') }} {{ app.manifest.version }}</div>
|
||||
<div>{{ manifest.title }} Version {{ app.manifest.upstreamVersion }}</div>
|
||||
<div><a :href="manifest.website" target="_blank">{{ manifest.website }}</a></div>
|
||||
<div class="summary" v-if="packageData.manifest">
|
||||
<div class="title"><img class="icon pankow-no-desktop" style="width: 32px; height: 32px; margin-right: 10px" :src="packageData.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>{{ manifest.title }}</div>
|
||||
<div><a :href="manifest.website" target="_blank">{{ manifest.title }}</a> {{ packageData.manifest.upstreamVersion }} - {{ $t('app.updates.info.packageVersion') }} {{ packageData.manifest.version }}</div>
|
||||
<div v-if="packageData.versionsUrl"><a :href="packageData.manifest.packagerUrl" target="_blank">{{ packageData.manifest.packagerName }}</a></div>
|
||||
<div>{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(packageData.creationDate) }) }}</div>
|
||||
<div>{{ $t('appstore.installDialog.memoryRequirement', { size: prettyBinarySize(manifest.memoryLimit || (256 * 1024 * 1024)) }) }}</div>
|
||||
<div>{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(app.creationDate) }) }}</div>
|
||||
</div>
|
||||
<img class="icon pankow-no-mobile" :src="app.iconUrl" />
|
||||
<img class="icon pankow-no-mobile" :src="packageData.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
|
||||
</div>
|
||||
<Transition name="slide-left" mode="out-in">
|
||||
<div v-if="step === STEP.DETAILS">
|
||||
@@ -309,15 +299,15 @@ defineExpose({
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
<div class="error-label" v-if="formError.dnsExists">{{ formError.dnsExists }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit(false)" autocomplete="off">
|
||||
<form :ref="(el) => { form = el; }" @submit.prevent="onSubmit(false)" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit" :disabled="!formValid" />
|
||||
<input style="display: none;" type="submit" :disabled="busy" />
|
||||
|
||||
<FormGroup :class="{ 'has-error': formError.location }">
|
||||
<label for="location">{{ $t('appstore.installDialog.location') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="location" ref="locationInput" v-model="location" style="flex-grow: 1"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-label="label" option-key="domain" @select="onDomainChange()" :search-threshold="10"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-label="label" option-key="domain" @select="onDomainChange()" :search-threshold="10" required/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-show="domainProvider === 'noop' || domainProvider === 'manual'" v-html="$t('appstore.installDialog.manualWarning', { location: ((location ? location + '.' : '') + domain) })"></div>
|
||||
<div class="error-label" v-if="formError.location">{{ formError.location }}</div>
|
||||
@@ -328,21 +318,21 @@ defineExpose({
|
||||
<small>{{ port.description }}</small>
|
||||
<InputGroup>
|
||||
<TextInput :id="'secondaryDomainInput' + key" v-model="port.value" :placeholder="$t('appstore.installDialog.locationPlaceholder')" style="flex-grow: 1"/>
|
||||
<SingleSelect v-model="port.domain" :options="domains" option-label="label" option-key="domain" />
|
||||
<SingleSelect v-model="port.domain" :options="domains" option-label="label" option-key="domain" required/>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :class="{ 'has-error': formError.upstreamUri }" v-show="manifest.id === PROXY_APP_ID">
|
||||
<FormGroup :class="{ 'has-error': formError.upstreamUri }" v-if="manifest.id === PROXY_APP_ID">
|
||||
<label for="upstreamUri">Upstream URI</label>
|
||||
<TextInput id="upstreamUri" v-model="upstreamUri" />
|
||||
<TextInput id="upstreamUri" v-model="upstreamUri" required/>
|
||||
</FormGroup>
|
||||
|
||||
<PortBindings v-model:tcp="tcpPorts" v-model:udp="udpPorts" :error="formError" :domain-provider="domainProvider"/>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="manifest" :users="users" :groups="groups" :installation="true"/>
|
||||
|
||||
<div class="bottom-button-bar">
|
||||
<Button v-if="needsOverwriteDns" danger @click="onSubmit(true)" icon="fa-solid fa-circle-down" :disabled="!formValid" :loading="busy">Install {{ manifest.title }} and overwrite DNS</Button>
|
||||
<Button v-else @click="onSubmit(false)" icon="fa-solid fa-circle-down" :disabled="!formValid" :loading="busy">Install {{ manifest.title }}</Button>
|
||||
<Button v-if="needsOverwriteDns" danger @click="onSubmit(true)" icon="fa-solid fa-circle-down" :disabled="busy || !isFormValid" :loading="busy">Install {{ manifest.title }} and overwrite DNS</Button>
|
||||
<Button v-else @click="onSubmit(false)" icon="fa-solid fa-circle-down" :disabled="busy || !isFormValid" :loading="busy">Install {{ manifest.title }}</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -370,7 +360,6 @@ defineExpose({
|
||||
|
||||
.app-install-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardButton, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
|
||||
import { Button, ClipboardButton, DateTimeInput, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import ActionBar from './ActionBar.vue';
|
||||
import Section from './Section.vue';
|
||||
@@ -35,7 +34,16 @@ const columns = {
|
||||
sort(a, b) {
|
||||
if (!a) return 1;
|
||||
if (!b) return -1;
|
||||
return moment(a).isBefore(b) ? 1 : -1;
|
||||
return new Date(a) - new Date(b);
|
||||
}
|
||||
},
|
||||
expiresAt: {
|
||||
label: t('profile.appPasswords.expires'),
|
||||
hideMobile: true,
|
||||
sort(a, b) {
|
||||
if (!a) return 1;
|
||||
if (!b) return -1;
|
||||
return new Date(a) - new Date(b);
|
||||
}
|
||||
},
|
||||
actions: {}
|
||||
@@ -54,22 +62,30 @@ const addedPassword = ref('');
|
||||
const passwordName = ref('');
|
||||
const identifiers = ref([]);
|
||||
const identifier = ref('');
|
||||
const expiresAtDate = ref('');
|
||||
const minExpiresAt = new Date().toISOString().slice(0, 16);
|
||||
const addError = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
const appsById = {};
|
||||
async function refresh() {
|
||||
const [error, result] = await appPasswordsModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
// setup label for the table UI
|
||||
result.forEach(function (password) {
|
||||
if (password.identifier === 'mail') return password.label = password.identifier;
|
||||
const app = appsById[password.identifier];
|
||||
if (!app) return password.label = password.identifier + ' (App not found)';
|
||||
for (const password of result) {
|
||||
if (password.identifier === 'mail') {
|
||||
password.label = password.identifier;
|
||||
} else {
|
||||
const app = appsById[password.identifier];
|
||||
if (!app) return password.label = password.identifier + ' (App not found)';
|
||||
|
||||
const ftp = app.manifest.addons && app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
|
||||
const labelSuffix = ftp ? ' - SFTP' : '';
|
||||
password.label = app.label ? app.label + ' (' + app.fqdn + ')' + labelSuffix : app.fqdn + labelSuffix;
|
||||
});
|
||||
const ftp = app.manifest.addons && app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
|
||||
const labelSuffix = ftp ? ' - SFTP' : '';
|
||||
password.label = app.label ? app.label + ' (' + app.fqdn + ')' + labelSuffix : app.fqdn + labelSuffix;
|
||||
}
|
||||
|
||||
password.expired = password.expiresAt && new Date(password.expiresAt) < new Date();
|
||||
}
|
||||
|
||||
passwords.value = result;
|
||||
}
|
||||
@@ -84,7 +100,10 @@ function onReset() {
|
||||
setTimeout(() => {
|
||||
passwordName.value = '';
|
||||
identifier.value = '';
|
||||
expiresAtDate.value = '';
|
||||
addedPassword.value = '';
|
||||
addError.value = '';
|
||||
busy.value = false;
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}, 500);
|
||||
}
|
||||
@@ -92,16 +111,26 @@ function onReset() {
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
addError.value = '';
|
||||
addedPassword.value = '';
|
||||
|
||||
const [error, result] = await appPasswordsModel.add(identifier.value, passwordName.value);
|
||||
if (error) return console.error(error);
|
||||
const expiresAt = expiresAtDate.value ? new Date(expiresAtDate.value).toISOString() : null;
|
||||
const [error, result] = await appPasswordsModel.add(identifier.value, passwordName.value, expiresAt);
|
||||
if (error) {
|
||||
busy.value = false;
|
||||
addError.value = error.body ? error.body.message : 'Internal error';
|
||||
return;
|
||||
}
|
||||
|
||||
addedPassword.value = result.password;
|
||||
passwordName.value = '';
|
||||
identifier.value = '';
|
||||
expiresAtDate.value = '';
|
||||
|
||||
await refresh();
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onRemove(appPassword) {
|
||||
@@ -160,7 +189,8 @@ onMounted(async () => {
|
||||
|
||||
<Dialog ref="newDialog"
|
||||
:title="$t('profile.createAppPassword.title')"
|
||||
:confirm-active="addedPassword || isFormValid"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="addedPassword || (!busy && isFormValid)"
|
||||
:confirm-label="addedPassword ? '' : $t('main.action.add')"
|
||||
confirm-style="primary"
|
||||
:reject-label="addedPassword ? $t('main.dialog.close') : $t('main.dialog.cancel')"
|
||||
@@ -169,19 +199,27 @@ onMounted(async () => {
|
||||
@close="onReset()"
|
||||
>
|
||||
<div>
|
||||
<div class="error-label" v-show="addError">{{ addError }}</div>
|
||||
<Transition name="slide-left" mode="out-in">
|
||||
<div v-if="!addedPassword">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<input style="display: none" type="submit"/>
|
||||
<FormGroup>
|
||||
<label for="passwordName">{{ $t('profile.createAppPassword.name') }}</label>
|
||||
<TextInput id="passwordName" v-model="passwordName" required/>
|
||||
</FormGroup>
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none" type="submit"/>
|
||||
<FormGroup>
|
||||
<label for="passwordName">{{ $t('profile.createAppPassword.name') }}</label>
|
||||
<TextInput id="passwordName" v-model="passwordName" required/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('profile.createAppPassword.app') }}</label>
|
||||
<SingleSelect outline v-model="identifier" :options="identifiers" option-label="label" option-key="id" required/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<label>{{ $t('profile.createAppPassword.app') }}</label>
|
||||
<SingleSelect outline v-model="identifier" :options="identifiers" option-label="label" option-key="id" required/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="expiresAt">{{ $t('profile.createAppPassword.expiresAt') }} (optional)</label>
|
||||
<DateTimeInput id="expiresAt" v-model="expiresAtDate" :min="minExpiresAt"/>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else>
|
||||
@@ -205,8 +243,14 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="passwords" :placeholder="$t('profile.appPasswords.noPasswordsPlaceholder')">
|
||||
<template #creationTime="password">{{ prettyLongDate(password.creationTime) }}</template>
|
||||
<template #actions="password">
|
||||
<template #name="{ item:password }"><span :class="{ 'text-muted': password.expired }">{{ password.name }}</span></template>
|
||||
<template #label="{ item:password }"><span :class="{ 'text-muted': password.expired }">{{ password.label }}</span></template>
|
||||
<template #creationTime="{ item:password }"><span :class="{ 'text-muted': password.expired }">{{ prettyLongDate(password.creationTime) }}</span></template>
|
||||
<template #expiresAt="{ item:password }">
|
||||
<span :class="{ 'text-muted': password.expired }" v-if="!password.expiresAt">-</span>
|
||||
<span :class="{ 'text-muted': password.expired }" v-else>{{ prettyLongDate(password.expiresAt) }}</span>
|
||||
</template>
|
||||
<template #actions="{ item:password }">
|
||||
<ActionBar :actions="createActionMenu(password)" />
|
||||
</template>
|
||||
</TableView>
|
||||
|
||||
@@ -208,6 +208,8 @@ defineExpose({
|
||||
<div class="error-label" v-show="formError.port">{{ formError.port }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<fieldset>
|
||||
<FormGroup>
|
||||
<label for="locationInput">{{ $t('app.cloneDialog.location') }}</label>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, FormGroup, InputDialog, MultiSelect, Radiobutton, TagInput, TextInput } from '@cloudron/pankow';
|
||||
import { API_ORIGIN } from '../constants.js';
|
||||
import { getDataURLFromFile } from '../utils.js';
|
||||
@@ -64,16 +64,17 @@ async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
const data = {
|
||||
label: label.value,
|
||||
upstreamUri: upstreamUri.value,
|
||||
tags: tags.value,
|
||||
};
|
||||
|
||||
if (label.value) data.label = label.value;
|
||||
|
||||
data.accessRestriction = null;
|
||||
if (accessRestrictionOption.value === 'groups') {
|
||||
data.accessRestriction = { users: [], groups: [] };
|
||||
data.accessRestriction.users = accessRestriction.value.users.map(function (u) { return u.id; });
|
||||
data.accessRestriction.groups = accessRestriction.value.groups.map(function (g) { return g.id; });
|
||||
data.accessRestriction.users = accessRestriction.value.users;
|
||||
data.accessRestriction.groups = accessRestriction.value.groups;
|
||||
}
|
||||
|
||||
if (iconFile === 'fallback') { // user reset the icon
|
||||
@@ -130,6 +131,7 @@ defineExpose({
|
||||
// fetch users and groups
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => { u.label = u.username || u.email; });
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
@@ -177,7 +179,7 @@ defineExpose({
|
||||
</FormGroup>
|
||||
|
||||
<div>
|
||||
<label for="previewIcon">{{ $t('app.display.icon') }}</label>
|
||||
<label>{{ $t('app.display.icon') }}</label>
|
||||
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" :fallback-src="`${API_ORIGIN}/img/appicon_fallback.png`" @changed="onIconChanged" :size="512" display-height="80px" style="width: 80px"/>
|
||||
</div>
|
||||
|
||||
@@ -188,7 +190,7 @@ defineExpose({
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#dashboard-visibility" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<label>{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#no-sso" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<Radiobutton v-model="accessRestrictionOption" value="any" :label="$t('app.accessControl.userManagement.visibleForAllUsers')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" value="groups" :label="$t('app.accessControl.userManagement.visibleForSelected')"/>
|
||||
<!-- <span class="label label-danger"v-show="accessRestrictionOption === 'groups' && !isAccessRestrictionValid(applinkDialogData)">{{ $t('appstore.installDialog.errorUserManagementSelectAtLeastOne') }}</span> -->
|
||||
@@ -197,10 +199,10 @@ defineExpose({
|
||||
<div v-if="accessRestrictionOption === 'groups'">
|
||||
<div style="margin-left: 20px; display: flex;">
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-label="username" :search-threshold="20" />
|
||||
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="label" :search-threshold="20" />
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-label="name" :search-threshold="20" />
|
||||
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ref, computed, useTemplateRef } from 'vue';
|
||||
import { ClipboardAction, TableView, Dialog } from '@cloudron/pankow';
|
||||
import { prettyDuration, prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
@@ -15,24 +15,39 @@ const backupsModel = BackupsModel.create();
|
||||
|
||||
const busy = ref(true);
|
||||
|
||||
const backupContentTableColumns = {
|
||||
label: {
|
||||
label: t('backups.listing.contents'),
|
||||
sort: true,
|
||||
},
|
||||
fileCount: {
|
||||
label: t('backup.target.fileCount'),
|
||||
sort(a, b, A, B) {
|
||||
return A.stats?.upload?.fileCount - B.stats?.upload?.fileCount;
|
||||
const backupContentTableColumns = computed(() => {
|
||||
const columns = {
|
||||
label: {
|
||||
label: t('backups.listing.contents'),
|
||||
sort: true,
|
||||
},
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort(a, b, A , B) {
|
||||
return A.stats?.upload?.size - B.stats?.upload?.size;
|
||||
fileCount: {
|
||||
label: t('backup.target.fileCount'),
|
||||
sort(a, b, A, B) {
|
||||
return A.stats?.upload?.fileCount - B.stats?.upload?.fileCount;
|
||||
},
|
||||
align: 'right',
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort(a, b, A , B) {
|
||||
return A.stats?.upload?.size - B.stats?.upload?.size;
|
||||
},
|
||||
align: 'right',
|
||||
},
|
||||
};
|
||||
|
||||
if (backup.value.lastIntegrityCheckTime || backup.value.integrityCheckTask) {
|
||||
columns.integrity = {
|
||||
label: 'Integrity',
|
||||
sort: false,
|
||||
width: '100px',
|
||||
align: 'center',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return columns;
|
||||
});
|
||||
|
||||
const backup = ref({ contents: [], validStats: false });
|
||||
const dialog = useTemplateRef('dialog');
|
||||
@@ -67,8 +82,11 @@ defineExpose({
|
||||
if (!match) continue;
|
||||
const [error, result] = await backupsModel.get(contentId);
|
||||
if (error) console.error(error);
|
||||
const content = { id: null, label: null, fqdn: null, stats: null };
|
||||
const content = { id: null, label: null, fqdn: null, stats: null, integrityCheckStatus: null, lastIntegrityCheckTime: null, integrityCheckTask: null };
|
||||
content.stats = result.stats;
|
||||
content.integrityCheckStatus = result.integrityCheckStatus;
|
||||
content.lastIntegrityCheckTime = result.lastIntegrityCheckTime;
|
||||
content.integrityCheckTask = result.integrityCheckTask;
|
||||
if (match[1] === 'mail') {
|
||||
content.id = 'mail';
|
||||
content.label = 'Mail Server';
|
||||
@@ -132,25 +150,53 @@ defineExpose({
|
||||
<div v-if="backup.type === 'box'" class="info-value">{{ prettyDuration(backup.stats.aggregatedUpload.duration + backup.stats.aggregatedCopy.duration) }}</div>
|
||||
<div v-else class="info-value">{{ prettyDuration(backup.stats.upload.duration + backup.stats.copy.duration) }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.lastIntegrityCheck') }}</div>
|
||||
<div class="info-value">
|
||||
<a v-if="backup.integrityCheckTask?.active" :href="`/logs.html?taskId=${backup.integrityCheckTask.id}`" target="_blank">{{ $t('backups.backupDetails.integrityInProgress') }}</a>
|
||||
<a v-else-if="backup.lastIntegrityCheckTime && backup.integrityCheckTask" :href="`/logs.html?taskId=${backup.integrityCheckTask.id}`" target="_blank">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }"></i>
|
||||
{{ prettyLongDate(backup.lastIntegrityCheckTime) }}
|
||||
</a>
|
||||
<span v-else-if="backup.lastIntegrityCheckTime">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }"></i>
|
||||
{{ prettyLongDate(backup.lastIntegrityCheckTime) }}
|
||||
</span>
|
||||
<span v-else>{{ $t('backups.backupDetails.integrityNever') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="(backup.integrityCheckStatus === 'failed' || backup.integrityCheckStatus === 'skipped') && backup.integrityCheckResult?.messages?.length">
|
||||
<div class="info-label" style="margin-bottom: 5px;">Integrity Issues</div>
|
||||
<textarea readonly rows="10" style="width: 100%; resize: vertical;" :value="backup.integrityCheckResult.messages.join('\n')"></textarea>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 15px 0" v-if="backup.type === 'box'"/>
|
||||
|
||||
<div v-if="backup.type === 'box'">
|
||||
<TableView :columns="backupContentTableColumns" :model="backup.contents" :busy="busy">
|
||||
<template #label="content">
|
||||
<template #label="{ item:content }">
|
||||
<a v-if="content.id === 'mail'" href="/#/mailboxes">{{ content.label }}</a>
|
||||
<a v-else-if="content.fqdn" :href="`/#/app/${content.id}/backups`">{{ content.label || content.fqdn }}</a>
|
||||
<a v-else :href="`/#/system-eventlog?search=${content.id}`">{{ content.id }}</a>
|
||||
</template>
|
||||
<template #fileCount="content">
|
||||
<template #fileCount="{ item:content }">
|
||||
<div v-if="content.stats?.upload" style="text-align: right">{{ content.stats.upload.fileCount }}</div>
|
||||
<div v-else style="text-align: right">-</div>
|
||||
</template>
|
||||
<!-- <td>{{ prettyDuration(content.stats.upload.duration | content.stats.copy.duration) }}</td> -->
|
||||
<template #size="content">
|
||||
<template #size="{ item:content }">
|
||||
<div v-if="content.stats?.upload" style="text-align: right">{{ prettyFileSize(content.stats.upload.size) }}</div>
|
||||
<div v-else style="text-align: right">-</div>
|
||||
</template>
|
||||
<template #integrity="{ item:content }">
|
||||
<a v-if="content.lastIntegrityCheckTime && content.integrityCheckTask" :href="`/logs.html?taskId=${content.integrityCheckTask.id}`" target="_blank" style="display: flex; align-items: center; justify-content: center;">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': content.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': content.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': content.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(content.lastIntegrityCheckTime)"></i>
|
||||
</a>
|
||||
<div v-else-if="content.lastIntegrityCheckTime" style="display: flex; align-items: center; justify-content: center;">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': content.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': content.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': content.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(content.lastIntegrityCheckTime)"></i>
|
||||
</div>
|
||||
<div v-else style="text-align: center;">-</div>
|
||||
</template>
|
||||
</TableView>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ref, useTemplateRef, watch } from 'vue';
|
||||
import { MaskedInput, Dialog, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
|
||||
import { prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import { s3like, mountlike, prettySiteLocation } from '../utils.js';
|
||||
import { s3like, mountlike } from '../utils.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
@@ -205,7 +205,7 @@ defineExpose({
|
||||
|
||||
<FormGroup v-if="site.provider && site.config">
|
||||
<label><i v-if="site.encrypted" class="fa-solid fa-lock"></i> Storage: <b>{{ site.provider }} ({{ site.format }}) </b></label>
|
||||
<div>{{ prettySiteLocation(site) }}</div>
|
||||
<div>{{ site.locationLabel }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup } from '@cloudron/pankow';
|
||||
import CommunityModel from '../models/CommunityModel.js';
|
||||
|
||||
const communityModel = CommunityModel.create();
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const form = useTemplateRef('form');
|
||||
|
||||
const formError = ref({});
|
||||
const versionsUrl = ref('');
|
||||
const busy = ref(false);
|
||||
const unstable = ref(false);
|
||||
|
||||
const isFormValid = ref(false);
|
||||
function validateForm() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
|
||||
const [url, version] = versionsUrl.value.split('@'); // hidden feature that user can input with version
|
||||
const [error, result] = await communityModel.getApp(url, version || 'latest');
|
||||
if (error) {
|
||||
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
unstable.value = !!result.unstable;
|
||||
|
||||
const packageData = {
|
||||
...result, // { manifest, publishState, creationDate, ts, unstable, versionsUrl }
|
||||
versionsUrl: result.versionsUrl,
|
||||
iconUrl: result.manifest.iconUrl // compat with app store format
|
||||
};
|
||||
|
||||
emit('success', packageData);
|
||||
|
||||
dialog.value.close();
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open() {
|
||||
versionsUrl.value = '';
|
||||
formError.value = {};
|
||||
unstable.value = false;
|
||||
dialog.value.open();
|
||||
setTimeout(validateForm, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
title="Install Community App"
|
||||
:confirm-label="$t('main.action.add')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
reject-style="secondary"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="validateForm()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" />
|
||||
|
||||
<div class="warning-label">{{ $t('communityapp.installwarning') }}</div>
|
||||
<div class="error-label" v-if="unstable">{{ $t('communityapp.unstablewarning') }}</div>
|
||||
|
||||
<FormGroup>
|
||||
<label for="urlInput">CloudronVersions.json URL</label>
|
||||
<TextInput id="urlInput" v-model="versionsUrl" required placeholder="https://example.com/CloudronVersions.json"/>
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, inject } from 'vue';
|
||||
import { Button, ProgressBar, SingleSelect, InputGroup } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { TASK_TYPES } from '../constants.js';
|
||||
@@ -14,6 +18,8 @@ const taskModel = TasksModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const inputDialog = inject('inputDialog');
|
||||
|
||||
const domains = ref([]);
|
||||
const formError = ref('');
|
||||
const originalDomain = ref('');
|
||||
@@ -64,6 +70,16 @@ async function refreshTasks() {
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const confirm = await inputDialog.value.confirm({
|
||||
title: t('domains.changeDashboardDomain.confirmTitle'),
|
||||
message: t('domains.changeDashboardDomain.confirmMessage'),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
if (!confirm) return;
|
||||
|
||||
formError.value = '';
|
||||
|
||||
lastTask.value.active = true;
|
||||
|
||||
@@ -12,6 +12,7 @@ const dialog = useTemplateRef('dialog');
|
||||
const formError = ref({});
|
||||
const busy = ref (false);
|
||||
const password = ref('');
|
||||
const twoFAMethod = ref('totp'); // 'totp' or 'passkey'
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
@@ -25,10 +26,19 @@ async function onSubmit() {
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
|
||||
const [error] = await profileModel.disableTwoFA(password.value);
|
||||
let error;
|
||||
if (twoFAMethod.value === 'passkey') {
|
||||
[error] = await profileModel.deletePasskey(password.value);
|
||||
} else {
|
||||
[error] = await profileModel.disableTwoFA(password.value);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error.status === 412) formError.value.password = error.body.message;
|
||||
else {
|
||||
if (error.status === 412) {
|
||||
password.value = '';
|
||||
formError.value.password = error.body.message;
|
||||
setTimeout(() => document.getElementById('passwordInput')?.focus(), 0);
|
||||
} else {
|
||||
formError.value.generic = error.status ? error.body.message : 'Internal error';
|
||||
console.error('Failed to disable 2fa', error);
|
||||
}
|
||||
@@ -46,7 +56,8 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open() {
|
||||
async open(method = 'totp') {
|
||||
twoFAMethod.value = method;
|
||||
password.value = '';
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
@@ -60,11 +71,11 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('profile.disable2FA.title')"
|
||||
:title="twoFAMethod === 'totp' ? $t('profile.disableTotp.title') : $t('profile.disablePasskey.title')"
|
||||
:confirm-label="$t('profile.disable2FA.disable')"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-busy="busy"
|
||||
confirm-style="primary"
|
||||
confirm-style="danger"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
reject-style="secondary"
|
||||
@@ -78,7 +89,7 @@ defineExpose({
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.disable2FA.password') }}</label>
|
||||
<PasswordInput v-model="password" required />
|
||||
<PasswordInput v-model="password" required id="passwordInput" />
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -75,10 +75,4 @@ onMounted(async () => {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.disks-last-updated {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -26,6 +26,8 @@ async function getUsage() {
|
||||
const [error, result] = await systemModel.filesystemUsage(props.filesystem.filesystem);
|
||||
if (error) return console.error(error);
|
||||
|
||||
showingCachedValue.value = false;
|
||||
|
||||
contents.value = [];
|
||||
|
||||
eventSource = result;
|
||||
@@ -36,7 +38,6 @@ async function getUsage() {
|
||||
if (payload.type === 'done') {
|
||||
percent.value = 100;
|
||||
ts.value = Date.now();
|
||||
showingCachedValue.value = false;
|
||||
|
||||
contents.value.forEach((c, i) => c.color = getColor(contents.value.length, i));
|
||||
contents.value.sort((a, b) => b.usage - a.usage);
|
||||
@@ -176,7 +177,7 @@ onUnmounted(() => {
|
||||
.disk-item-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@@ -224,7 +225,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
tr.highlight {
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -104,7 +104,7 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="registries" :busy="busy" :placeholder="$t('dockerRegistries.emptyPlaceholder')">
|
||||
<template #actions="registry">
|
||||
<template #actions="{ item:registry }">
|
||||
<ActionBar :actions="createActionMenu(registry)"/>
|
||||
</template>
|
||||
</TableView>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { TextInput, InputGroup, MaskedInput, Button, FormGroup, Checkbox, SingleSelect } from '@cloudron/pankow';
|
||||
import { ENDPOINTS_OVH } from '../constants.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
@@ -58,7 +58,7 @@ function resetFields() {
|
||||
dnsConfig.value.accessKey = '';
|
||||
dnsConfig.value.accessToken = '';
|
||||
dnsConfig.value.apiKey = '';
|
||||
dnsConfig.value.apikey = '';
|
||||
dnsConfig.value.appKey = '';
|
||||
dnsConfig.value.appSecret = '';
|
||||
dnsConfig.value.apiPassword = '';
|
||||
dnsConfig.value.apiSecret = '';
|
||||
@@ -77,7 +77,7 @@ function resetFields() {
|
||||
dnsConfig.value.username = '';
|
||||
}
|
||||
|
||||
watch(provider, (p) => {
|
||||
function onProviderChange(p) {
|
||||
resetFields();
|
||||
|
||||
// wildcard LE won't work without automated DNS
|
||||
@@ -86,7 +86,7 @@ watch(provider, (p) => {
|
||||
} else {
|
||||
tlsProvider.value = 'letsencrypt-prod-wildcard';
|
||||
}
|
||||
}, { immediate: true });
|
||||
}
|
||||
|
||||
const gcdnsFileParseError = ref('');
|
||||
function onGcdnsFileInputChange(event) {
|
||||
@@ -127,7 +127,7 @@ function onGcdnsFileInputChange(event) {
|
||||
<div>
|
||||
<FormGroup>
|
||||
<label for="providerInput">{{ $t('domains.domainDialog.provider') }} <sup><a href="https://docs.cloudron.io/domains/#dns-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect v-model="provider" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" required />
|
||||
<SingleSelect v-model="provider" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" required @select="onProviderChange"/>
|
||||
</FormGroup>
|
||||
|
||||
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardAction, Dialog, TextInput, InputGroup, FormGroup } from '@cloudron/pankow';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const props = defineProps({
|
||||
mandatory2FA: { type: Boolean, default: false },
|
||||
has2FA: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const profileModel = ProfileModel.create();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const setupMode = ref('');
|
||||
const totpSecret = ref('');
|
||||
const totpToken = ref('');
|
||||
const totpQRCode = ref('');
|
||||
const totpEnableError = ref('');
|
||||
const passkeyRegisterError = ref('');
|
||||
const passkeyRegisterBusy = ref(false);
|
||||
|
||||
async function onTotpEnable() {
|
||||
const [error] = await profileModel.enableTotp(totpToken.value);
|
||||
if (error) {
|
||||
totpToken.value = '';
|
||||
return totpEnableError.value = error.body ? error.body.message : 'Internal error';
|
||||
}
|
||||
|
||||
emit('success');
|
||||
dialog.value.close();
|
||||
}
|
||||
|
||||
async function onRegisterPasskey() {
|
||||
passkeyRegisterBusy.value = true;
|
||||
passkeyRegisterError.value = '';
|
||||
|
||||
try {
|
||||
const [optionsError, options] = await profileModel.getPasskeyRegistrationOptions();
|
||||
if (optionsError) {
|
||||
passkeyRegisterError.value = optionsError.body?.message || 'Failed to get registration options';
|
||||
passkeyRegisterBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
|
||||
const [registerError] = await profileModel.registerPasskey(credential, 'Cloudron');
|
||||
if (registerError) {
|
||||
passkeyRegisterError.value = registerError.body?.message || 'Failed to register passkey';
|
||||
passkeyRegisterBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
emit('success');
|
||||
dialog.value.close();
|
||||
} catch (error) {
|
||||
passkeyRegisterError.value = error.message || 'Passkey registration failed';
|
||||
}
|
||||
|
||||
passkeyRegisterBusy.value = false;
|
||||
}
|
||||
|
||||
async function loadTotpSecret() {
|
||||
const [error, result] = await profileModel.setTotpSecret();
|
||||
if (error) return console.error(error);
|
||||
|
||||
totpSecret.value = result.secret;
|
||||
totpQRCode.value = result.qrcode;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open(method) {
|
||||
setupMode.value = method || 'passkey';
|
||||
totpEnableError.value = '';
|
||||
totpToken.value = '';
|
||||
passkeyRegisterError.value = '';
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
if (setupMode.value === 'totp') await loadTotpSecret();
|
||||
},
|
||||
close() {
|
||||
dialog.value.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function switchMode(mode) {
|
||||
setupMode.value = mode;
|
||||
if (mode === 'totp' && !totpSecret.value) await loadTotpSecret();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog" :title="setupMode === 'totp' ? $t('profile.enableTotp.title') : $t('profile.enablePasskey.title')" :dismissable="!props.mandatory2FA || props.has2FA">
|
||||
<div>
|
||||
<p class="text-warning" v-if="props.mandatory2FA && !props.has2FA">{{ $t('profile.enable2FA.mandatorySetup') }}</p>
|
||||
|
||||
<!-- Passkey Setup -->
|
||||
<div v-if="setupMode === 'passkey'">
|
||||
<p v-html="$t('profile.enable2FA.passkeyDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
|
||||
<div style="text-align: center;">
|
||||
<Button @click="onRegisterPasskey()" :loading="passkeyRegisterBusy" :disabled="passkeyRegisterBusy">{{ $t('profile.enable2FA.registerPasskey') }}</Button>
|
||||
<div class="error-label" v-if="passkeyRegisterError">{{ passkeyRegisterError }}</div>
|
||||
</div>
|
||||
<p v-if="props.mandatory2FA && !props.has2FA" style="text-align: center; margin-top: 15px;">
|
||||
<a href="#" @click.prevent="switchMode('totp')">{{ $t('profile.enable2FA.switchToTotp') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- TOTP Setup -->
|
||||
<div v-if="setupMode === 'totp'">
|
||||
<p v-html="$t('profile.enable2FA.authenticatorAppDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
|
||||
<div style="text-align: center;">
|
||||
<img :src="totpQRCode" style="border-radius: 10px; margin-bottom: 10px"/>
|
||||
<small>{{ totpSecret }} <ClipboardAction plain :value="totpSecret"/></small>
|
||||
</div>
|
||||
<br/>
|
||||
<form @submit.prevent="onTotpEnable()">
|
||||
<input type="submit" style="display: none;" :disabled="!totpToken"/>
|
||||
<FormGroup style="text-align: left;">
|
||||
<label for="totpTokenInput">{{ $t('profile.enable2FA.token') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput v-model="totpToken" id="totpTokenInput" style="flex-grow: 1;"/>
|
||||
<Button @click="onTotpEnable()" :disabled="!totpToken">{{ $t('profile.enable2FA.enable') }}</Button>
|
||||
</InputGroup>
|
||||
<div class="error-label" v-if="totpEnableError">{{ totpEnableError }}</div>
|
||||
</FormGroup>
|
||||
</form>
|
||||
<p v-if="props.mandatory2FA && !props.has2FA" style="text-align: center; margin-top: 15px;">
|
||||
<a href="#" @click.prevent="switchMode('passkey')">{{ $t('profile.enable2FA.switchToPasskey') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, reactive, computed, onMounted, watch, nextTick, useTemplateRef } from 'vue';
|
||||
import { Button, TextInput, MultiSelect, Popover, FormGroup, DateTimeInput } from '@cloudron/pankow';
|
||||
import { useDebouncedRef, prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import { eventlogDetails, eventlogSource } from '../utils.js';
|
||||
|
||||
const props = defineProps({
|
||||
fetchPage: { type: Function, required: true },
|
||||
availableActions: { type: Array, default: () => [] },
|
||||
app: { type: Object, default: null },
|
||||
showToolbar: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
|
||||
const apps = ref([]);
|
||||
const eventlogs = ref([]);
|
||||
const refreshBusy = ref(false);
|
||||
const page = ref(1);
|
||||
const perPage = ref(100);
|
||||
const eventlogContainer = useTemplateRef('eventlogContainer');
|
||||
const actions = reactive([]);
|
||||
|
||||
const highlight = useDebouncedRef('', 300);
|
||||
const currentMatchPosition = ref(-1);
|
||||
const searching = ref(false);
|
||||
const SEARCH_LOOKAHEAD_PAGES = 5;
|
||||
|
||||
const filterFrom = ref('');
|
||||
const filterTo = ref('');
|
||||
const dateFilterPopover = useTemplateRef('dateFilterPopover');
|
||||
const dateFilterButton = useTemplateRef('dateFilterButton');
|
||||
|
||||
function getApp(id) {
|
||||
return apps.value.find(a => a.id === id);
|
||||
}
|
||||
|
||||
function processEvent(e) {
|
||||
const app = props.app || (e.data?.appId ? getApp(e.data.appId) : null);
|
||||
return {
|
||||
id: Symbol(),
|
||||
raw: e,
|
||||
details: eventlogDetails(e, app, props.app?.id || ''),
|
||||
source: eventlogSource(e, app),
|
||||
};
|
||||
}
|
||||
|
||||
function isMatch(eventlog, term) {
|
||||
if (!term) return false;
|
||||
const t = term.toLowerCase();
|
||||
if (eventlog.source.toLowerCase().includes(t)) return true;
|
||||
if (eventlog.details.replace(/<[^>]+>/g, '').toLowerCase().includes(t)) return true;
|
||||
if (JSON.stringify(eventlog.raw.data).toLowerCase().includes(t)) return true;
|
||||
if (eventlog.raw.action.toLowerCase().includes(t)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchIndices = computed(() => {
|
||||
if (!highlight.value) return [];
|
||||
return eventlogs.value.reduce((acc, e, i) => {
|
||||
if (isMatch(e, highlight.value)) acc.push(i);
|
||||
return acc;
|
||||
}, []);
|
||||
});
|
||||
|
||||
function scrollToIndex(idx) {
|
||||
const el = eventlogContainer.value?.querySelector(`[data-index="${idx}"]`);
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
}
|
||||
|
||||
function goToPrevMatch() {
|
||||
if (currentMatchPosition.value > 0) {
|
||||
currentMatchPosition.value--;
|
||||
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
|
||||
}
|
||||
}
|
||||
|
||||
async function goToNextMatch() {
|
||||
if (!highlight.value || searching.value) return;
|
||||
|
||||
if (currentMatchPosition.value + 1 < matchIndices.value.length) {
|
||||
currentMatchPosition.value++;
|
||||
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
|
||||
return;
|
||||
}
|
||||
|
||||
searching.value = true;
|
||||
let endOfLog = false;
|
||||
for (let i = 0; i < SEARCH_LOOKAHEAD_PAGES; i++) {
|
||||
const prevLength = eventlogs.value.length;
|
||||
await fetchMore();
|
||||
if (eventlogs.value.length === prevLength) { endOfLog = true; break; }
|
||||
|
||||
if (currentMatchPosition.value + 1 < matchIndices.value.length) {
|
||||
currentMatchPosition.value++;
|
||||
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
|
||||
searching.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
searching.value = false;
|
||||
if (endOfLog) window.pankow.notify({ text: `No more matches for "${highlight.value}".`, timeout: 3000 });
|
||||
else window.pankow.notify({ text: `No match found for "${highlight.value}" in ${eventlogs.value.length} entries. Click next to keep searching.`, timeout: 3000 });
|
||||
}
|
||||
|
||||
function buildFilter() {
|
||||
const filter = {};
|
||||
if (actions.length) filter.actions = actions.join(',');
|
||||
if (filterFrom.value) filter.from = new Date(filterFrom.value + 'T00:00:00').toISOString();
|
||||
if (filterTo.value) filter.to = new Date(filterTo.value + 'T23:59:59.999').toISOString();
|
||||
return filter;
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
highlight.value = '';
|
||||
refreshBusy.value = true;
|
||||
page.value = 1;
|
||||
|
||||
const [error, result] = await props.fetchPage(buildFilter(), page.value, perPage.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = result.map(processEvent);
|
||||
refreshBusy.value = false;
|
||||
}
|
||||
|
||||
async function fetchMore() {
|
||||
page.value++;
|
||||
|
||||
const [error, result] = await props.fetchPage(buildFilter(), page.value, perPage.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = eventlogs.value.concat(result.map(processEvent));
|
||||
}
|
||||
|
||||
async function onScroll(event) {
|
||||
if (event.target.scrollTop + event.target.clientHeight >= event.target.scrollHeight) await fetchMore();
|
||||
}
|
||||
|
||||
function onOpenDateFilter(event) {
|
||||
dateFilterPopover.value.open(event, dateFilterButton.value.$el || dateFilterButton.value);
|
||||
}
|
||||
|
||||
watch(actions, onRefresh);
|
||||
watch(filterFrom, onRefresh);
|
||||
watch(filterTo, onRefresh);
|
||||
watch(highlight, async () => {
|
||||
if (matchIndices.value.length > 0) {
|
||||
currentMatchPosition.value = 0;
|
||||
await nextTick();
|
||||
scrollToIndex(matchIndices.value[0]);
|
||||
} else {
|
||||
currentMatchPosition.value = -1;
|
||||
if (highlight.value) goToNextMatch();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.app) {
|
||||
const [error, result] = await appsModel.list();
|
||||
if (error) console.error(error);
|
||||
else apps.value = result;
|
||||
}
|
||||
|
||||
onRefresh();
|
||||
|
||||
while (eventlogContainer.value && eventlogContainer.value.scrollHeight <= eventlogContainer.value.offsetHeight) {
|
||||
await fetchMore();
|
||||
}
|
||||
});
|
||||
|
||||
function setHighlight(value) { highlight.value = value; }
|
||||
|
||||
defineExpose({ refresh: onRefresh, setHighlight });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="overflow: hidden; display: flex; flex-direction: column; height: 100%;">
|
||||
<div v-if="showToolbar" style="display: flex; align-items: center; gap: 5px; flex-wrap: wrap; padding-bottom: 10px; justify-content: flex-end;">
|
||||
<TextInput placeholder="Highlight..." v-model="highlight" @keydown.enter="goToNextMatch()"/>
|
||||
<Button tool plain :disabled="!highlight || currentMatchPosition <= 0 || searching" @click="goToPrevMatch()" icon="fa-solid fa-chevron-up" />
|
||||
<Button tool plain :disabled="!highlight || searching" :loading="searching" @click="goToNextMatch()" icon="fa-solid fa-chevron-down" />
|
||||
<Button tool secondary ref="dateFilterButton" @click="onOpenDateFilter($event)" :icon="(filterFrom || filterTo) ? 'fa-solid fa-calendar-check' : 'fa-solid fa-calendar'" />
|
||||
<MultiSelect v-if="availableActions.length" :search-threshold="10" v-model="actions" :options="availableActions" option-label="label" option-key="id" :selected-label="actions.length ? $t('main.multiselect.selected', { n: actions.length }) : $t('eventlog.filterAllEvents')"/>
|
||||
<Button tool secondary @click="onRefresh()" :loading="refreshBusy" icon="fa-solid fa-sync-alt" />
|
||||
</div>
|
||||
<Popover ref="dateFilterPopover" width="300px">
|
||||
<div style="padding: 15px; display: flex; flex-direction: column; gap: 10px;">
|
||||
<FormGroup>
|
||||
<label>From</label>
|
||||
<DateTimeInput date-only v-model="filterFrom" :max="filterTo || undefined" />
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<label>To</label>
|
||||
<DateTimeInput date-only v-model="filterTo" :min="filterFrom || undefined" />
|
||||
</FormGroup>
|
||||
</div>
|
||||
</Popover>
|
||||
<div ref="eventlogContainer" class="section-body" style="overflow-y: auto; overflow-x: hidden; flex: 1;" @scroll="onScroll">
|
||||
<table class="eventlog-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 160px;">{{ $t('eventlog.time') }}</th>
|
||||
<th style="width: 15%;">{{ $t('eventlog.source') }}</th>
|
||||
<th>{{ $t('eventlog.details') }}</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="(eventlog, index) in eventlogs" :key="eventlog.id">
|
||||
<tr :data-index="index" :class="{ 'active': eventlog.isOpen, 'eventlog-match': highlight && isMatch(eventlog, highlight), 'eventlog-match-current': matchIndices[currentMatchPosition] === index }" @click="eventlog.isOpen = !eventlog.isOpen">
|
||||
<td style="white-space: nowrap;">{{ prettyLongDate(eventlog.raw.creationTime) }}</td>
|
||||
<td class="eventlog-source">{{ eventlog.source }}</td>
|
||||
<td style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" v-html="eventlog.details"></td>
|
||||
<td><Button v-if="eventlog.raw.data.taskId" @click.stop plain small tool :href="app ? `/logs.html?appId=${app.id}&taskId=${eventlog.raw.data.taskId}` : `/logs.html?taskId=${eventlog.raw.data.taskId}`" target="_blank">Logs</Button></td>
|
||||
</tr>
|
||||
<tr v-show="eventlog.isOpen">
|
||||
<td colspan="4" class="eventlog-details" @click.stop>
|
||||
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.eventlog-table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.eventlog-table th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.eventlog-table th,
|
||||
.eventlog-table td {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.active,
|
||||
.eventlog-table tbody tr:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-source {
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.eventlog-details {
|
||||
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.eventlog-details pre {
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.eventlog-match {
|
||||
background-color: rgba(255, 193, 7, 0.15);
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.eventlog-match-current {
|
||||
background-color: rgba(255, 193, 7, 0.35);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, onMounted } from 'vue';
|
||||
import { RouterView } from 'vue-router';
|
||||
import { fetcher } from '@cloudron/pankow';
|
||||
import OfflineOverlay from '../components/OfflineOverlay.vue';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
@@ -9,6 +10,7 @@ const profileModel = ProfileModel.create();
|
||||
|
||||
const offlineOverlay = useTemplateRef('offlineOverlay');
|
||||
const ready = ref(false);
|
||||
const serviceDown = ref(false);
|
||||
|
||||
function onOnline() {
|
||||
ready.value = true;
|
||||
@@ -24,6 +26,9 @@ fetcher.globalOptions.errorHook = (error) => {
|
||||
// re-login will make the code get a new token
|
||||
if (error.status === 401) return profileModel.logout();
|
||||
|
||||
// if sftp addon is down, tell the user
|
||||
if (error.status === 424) return serviceDown.value = true;
|
||||
|
||||
if (error.status === 500 || error.status === 501) {
|
||||
// actual internal server error, most likely a bug or timeout log to console only to not alert the user
|
||||
if (!ready.value) {
|
||||
@@ -48,6 +53,23 @@ onMounted(() => {
|
||||
<template>
|
||||
<div style="height: 100%;">
|
||||
<OfflineOverlay ref="offlineOverlay" @online="onOnline()"/>
|
||||
<router-view v-if="ready"></router-view>
|
||||
<div v-if="ready && serviceDown" class="service-down">
|
||||
<div>
|
||||
Unable to connect to filemanager service. Check the status and logs in <a href="/#/services">Services view</a>.
|
||||
</div>
|
||||
</div>
|
||||
<router-view v-if="ready" v-show="!serviceDown"></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.service-down {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -4,9 +4,9 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, computed, useTemplateRef } from 'vue';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { useRouter, useRoute, onBeforeRouteUpdate } from 'vue-router';
|
||||
import { fetcher, Dialog, DirectoryView, TopBar, Breadcrumb, Button, InputDialog, MainLayout, ButtonGroup, Notification, FileUploader, Spinner } from '@cloudron/pankow';
|
||||
import { fetcher, Dialog, DirectoryView, TreeView, TopBar, Button, InputDialog, MainLayout, ButtonGroup, Notification, FileUploader, Spinner } from '@cloudron/pankow';
|
||||
import { sanitize, sleep } from '@cloudron/pankow/utils';
|
||||
import { API_ORIGIN, BASE_URL, ISTATES } from '../constants.js';
|
||||
import PreviewPanel from '../components/PreviewPanel.vue';
|
||||
@@ -33,7 +33,6 @@ const extractInProgressDialog = useTemplateRef('extractInProgressDialog');
|
||||
const busy = ref(true);
|
||||
const fallbackIcon = ref(`${BASE_URL}mime-types/none.svg`);
|
||||
const cwd = ref('/');
|
||||
const busyRefresh = ref(false);
|
||||
const busyRestart = ref(false);
|
||||
const fatalError = ref(false);
|
||||
const activeItem = ref(null);
|
||||
@@ -68,32 +67,6 @@ const uploadMenuModel = [{
|
||||
action: onUploadFolder,
|
||||
}];
|
||||
|
||||
const breadcrumbHomeItem = {
|
||||
label: '/app/data/',
|
||||
action: () => onActivateBreadcrumb('/'),
|
||||
};
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (!cwd.value) return [];
|
||||
|
||||
const parts = cwd.value.split('/').filter((p) => !!p.trim());
|
||||
const crumbs = [];
|
||||
|
||||
parts.forEach((p, i) => {
|
||||
crumbs.push({
|
||||
label: p,
|
||||
action: () => onActivateBreadcrumb('/' + parts.slice(0, i+1).join('/')),
|
||||
});
|
||||
});
|
||||
|
||||
return crumbs;
|
||||
});
|
||||
|
||||
// watch(() => {
|
||||
// if (resourceType.value && resourceId.value) router.push(`/home/${resourceType.value}/${resourceId.value}${cwd.value}`);
|
||||
// loadCwd();
|
||||
// });
|
||||
|
||||
function onFatalError(errorMessage) {
|
||||
fatalError.value = errorMessage;
|
||||
fatalErrorDialog.value.open();
|
||||
@@ -155,14 +128,39 @@ function onSelectionChanged(items) {
|
||||
selectedItems.value = items;
|
||||
}
|
||||
|
||||
function onActivateBreadcrumb(path) {
|
||||
router.push(`/home/${resourceType.value}/${resourceId.value}${sanitize(path)}`);
|
||||
function onTreeNavigate(event) {
|
||||
router.push(`/home/${resourceType.value}/${resourceId.value}${sanitize(event.path)}`);
|
||||
}
|
||||
|
||||
async function onTreeDrop(targetPath, event) {
|
||||
// check if this is an internal pankow drag (files from DirectoryView)
|
||||
if (event.dataTransfer.getData('application/x-pankow') === 'selected') {
|
||||
const files = selectedItems.value;
|
||||
if (!files || !files.length) return;
|
||||
|
||||
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
|
||||
pasteInProgressDialog.value.open();
|
||||
|
||||
try {
|
||||
await directoryModel.paste(targetPath, 'cut', files);
|
||||
} catch (e) {
|
||||
window.pankow.notify({ type: 'danger', text: e, persistent: true });
|
||||
}
|
||||
|
||||
await loadCwd();
|
||||
|
||||
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
|
||||
pasteInProgressDialog.value.close();
|
||||
}
|
||||
}
|
||||
|
||||
function treeListFiles(path) {
|
||||
if (!directoryModel) return [];
|
||||
return directoryModel.listFiles(path);
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
busyRefresh.value = true;
|
||||
await loadCwd();
|
||||
setTimeout(() => { busyRefresh.value = false; }, 500);
|
||||
}
|
||||
|
||||
// either dataTransfer (external drop) or files (internal drag)
|
||||
@@ -177,37 +175,66 @@ async function onDrop(targetFolder, dataTransfer, files) {
|
||||
});
|
||||
}
|
||||
|
||||
async function readEntries(dirReader) {
|
||||
return new Promise((resolve, reject) => {
|
||||
dirReader.readEntries(resolve, reject);
|
||||
});
|
||||
function setRelativePath(file, entry) {
|
||||
const relativePath = (entry.fullPath || entry.name || '').replace(/^\//, '');
|
||||
if (relativePath) {
|
||||
// trying with defineProperty() to better mimic native behavior adding a non-enumeratible property
|
||||
try {
|
||||
Object.defineProperty(file, 'webkitRelativePath', { value: relativePath });
|
||||
} catch {
|
||||
file.webkitRelativePath = relativePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// wrapper as chrome only returns files in batches of 100 entries
|
||||
async function readAllEntries(dirReader) {
|
||||
const all = [];
|
||||
let batch;
|
||||
do {
|
||||
batch = await new Promise((resolve, reject) => {
|
||||
dirReader.readEntries(resolve, reject);
|
||||
});
|
||||
all.push(...batch);
|
||||
} while (batch.length > 0);
|
||||
return all;
|
||||
}
|
||||
|
||||
const fileList = [];
|
||||
async function traverseFileTree(item) {
|
||||
if (item.isFile) {
|
||||
fileList.push(await getFile(item));
|
||||
const file = await getFile(item);
|
||||
setRelativePath(file, item);
|
||||
fileList.push(file);
|
||||
} else if (item.isDirectory) {
|
||||
// Get folder contents
|
||||
const dirReader = item.createReader();
|
||||
const entries = await readEntries(dirReader);
|
||||
const entries = await readAllEntries(dirReader);
|
||||
|
||||
for (const i in entries) {
|
||||
await traverseFileTree(entries[i], item.name);
|
||||
await traverseFileTree(entries[i]);
|
||||
}
|
||||
} else {
|
||||
console.log('Skipping uknown file type', item);
|
||||
console.log('Skipping unknown file type', item);
|
||||
}
|
||||
}
|
||||
|
||||
// collect all files to upload
|
||||
for (const item of dataTransfer.items) {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||||
|
||||
if (!entry) {
|
||||
const file = item.getAsFile ? item.getAsFile() : null;
|
||||
if (file) fileList.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile) {
|
||||
fileList.push(await getFile(entry));
|
||||
const file = await getFile(entry);
|
||||
setRelativePath(file, entry);
|
||||
fileList.push(file);
|
||||
} else if (entry.isDirectory) {
|
||||
await traverseFileTree(entry, sanitize(`${cwd.value}/${targetFolder}`));
|
||||
await traverseFileTree(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,8 +536,8 @@ onMounted(async () => {
|
||||
<template #header>
|
||||
<TopBar class="navbar">
|
||||
<template #left>
|
||||
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain tool style="margin-right: 10px"/>
|
||||
<Breadcrumb :home="breadcrumbHomeItem" :items="breadcrumbItems" :activate-handler="onActivateBreadcrumb"/>
|
||||
<a v-if="appLink" class="title" :href="appLink" target="_blank">{{ title }}</a>
|
||||
<span v-else class="title">{{ title }}</span>
|
||||
</template>
|
||||
<template #right>
|
||||
<ButtonGroup>
|
||||
@@ -521,7 +548,7 @@ onMounted(async () => {
|
||||
<Button style="margin: 0 20px;" v-tooltip="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button :href="'/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" v-tooltip="$t('terminal.title')" />
|
||||
<Button :href="'/terminal.html?id=' + resourceId + '&cwd=/app/data' + cwd" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" v-tooltip="$t('terminal.title')" />
|
||||
<Button :href="'/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-align-left" v-tooltip="$t('logs.title')" />
|
||||
</ButtonGroup>
|
||||
</template>
|
||||
@@ -529,6 +556,17 @@ onMounted(async () => {
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="main-view">
|
||||
<div class="main-view-col tree-view-col">
|
||||
<TreeView
|
||||
v-if="!busy"
|
||||
:list-files="treeListFiles"
|
||||
:active-path="cwd"
|
||||
:fallback-icon="fallbackIcon"
|
||||
root-label="/app/data"
|
||||
:drop-handler="onTreeDrop"
|
||||
@navigate="onTreeNavigate"
|
||||
/>
|
||||
</div>
|
||||
<div class="main-view-col">
|
||||
<DirectoryView
|
||||
class="directory-view"
|
||||
@@ -548,6 +586,7 @@ onMounted(async () => {
|
||||
:new-folder-handler="onNewFolder"
|
||||
:upload-file-handler="onUploadFile"
|
||||
:upload-folder-handler="onUploadFolder"
|
||||
:refresh-handler="onRefresh"
|
||||
:drop-handler="onDrop"
|
||||
:items="items"
|
||||
:owners-model="ownersModel"
|
||||
@@ -556,10 +595,6 @@ onMounted(async () => {
|
||||
/>
|
||||
</div>
|
||||
<div class="main-view-col" style="max-width: 300px;">
|
||||
<div class="side-bar-title">
|
||||
<a v-show="appLink" :href="appLink" target="_blank" class="no-highlight">{{ title }}</a>
|
||||
<span v-show="!appLink">{{ title }}</span>
|
||||
</div>
|
||||
<PreviewPanel :item="activeItem || activeDirectoryItem" :fallback-icon="fallbackIcon"/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -586,17 +621,20 @@ onMounted(async () => {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.side-bar-title {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.main-view-col {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.no-highlight {
|
||||
.tree-view-col {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
width: 250px;
|
||||
border-right: 1px solid var(--pankow-input-border-color);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
color: var(--pankow-color-text);
|
||||
}
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ defineExpose({
|
||||
.footer {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,11 @@ const t = i18n.t;
|
||||
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, inject } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { eachLimit } from 'async';
|
||||
import { Menu, Button, Popover, Icon, InputDialog, Spinner } from '@cloudron/pankow';
|
||||
import { prettyDate, prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import NotificationsModel from '../models/NotificationsModel.js';
|
||||
import { Menu, Popover, Icon, InputDialog, Spinner } from '@cloudron/pankow';
|
||||
import ServicesModel from '../models/ServicesModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
defineProps(['config', 'subscription']);
|
||||
defineProps(['config', 'subscription', 'notificationCount']);
|
||||
|
||||
const profile = inject('profile');
|
||||
|
||||
@@ -27,58 +24,6 @@ function onOpenHelp(popover, event, elem) {
|
||||
const servicesModel = ServicesModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
|
||||
const notificationModel = NotificationsModel.create();
|
||||
const notificationButton = useTemplateRef('notificationButton');
|
||||
const notificationPopover = useTemplateRef('notificationPopover');
|
||||
const notifications = ref([]);
|
||||
const notificationsAllBusy = ref(false);
|
||||
|
||||
function onOpenNotifications(popover, event, elem) {
|
||||
popover.open(event, elem);
|
||||
|
||||
// close after 2 seconds if there is nothing to show
|
||||
if (notifications.value.length === 0) setTimeout(popover.close, 2000);
|
||||
}
|
||||
|
||||
async function onMarkNotificationRead(notification) {
|
||||
notification.busy = true;
|
||||
const [error] = await notificationModel.update(notification.id, true);
|
||||
if (error) return console.error(error);
|
||||
|
||||
await refresh();
|
||||
|
||||
// close after 2 seconds if there is nothing to show
|
||||
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
|
||||
}
|
||||
|
||||
async function onMarkAllNotificationRead() {
|
||||
notificationsAllBusy.value = true;
|
||||
|
||||
await eachLimit(notifications.value, 5, async (notification) => {
|
||||
notification.busy = true;
|
||||
const [error] = await notificationModel.update(notification.id, true);
|
||||
if (error) return console.error(error);
|
||||
});
|
||||
|
||||
await refresh();
|
||||
|
||||
notificationsAllBusy.value = false;
|
||||
|
||||
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const [error, result] = await notificationModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
result.forEach(n => {
|
||||
n.isCollapsed = true;
|
||||
n.busy = false;
|
||||
});
|
||||
|
||||
notifications.value = result;
|
||||
}
|
||||
|
||||
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
|
||||
|
||||
function onSubscriptionRequired() {
|
||||
@@ -134,8 +79,6 @@ function onAvatarClick(event) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (profile.value.isAtLeastAdmin) await refresh();
|
||||
|
||||
await trackPlatformStatus();
|
||||
});
|
||||
|
||||
@@ -150,30 +93,6 @@ onUnmounted(() => {
|
||||
<InputDialog ref="inputDialog"/>
|
||||
<Menu ref="avatarMenu" :model="avatarActions" />
|
||||
|
||||
<Popover ref="notificationPopover" :width="'min(80%, 400px)'" :height="'min(80%, 600px)'">
|
||||
<div style="padding: 10px; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
||||
<div v-if="notifications.length" style="overflow: auto; margin-bottom: 10px">
|
||||
<div class="notification-item" v-for="notification in notifications" :key="notification.id">
|
||||
<div class="notification-item-title" @click="notification.isCollapsed = !notification.isCollapsed">
|
||||
<div>
|
||||
{{ notification.title }}<br/>
|
||||
<span class="notification-item-date" v-tooltip="prettyLongDate(notification.creationTime)">{{ prettyDate(notification.creationTime) }}</span>
|
||||
</div>
|
||||
<Button plain small tool :loading="notification.busy" :disabled="notification.busy" class="notification-read-button" @click.stop="onMarkNotificationRead(notification)">{{ $t('notifications.dismissTooltip') }}</Button>
|
||||
</div>
|
||||
<div class="notification-item-body" v-if="!notification.isCollapsed">
|
||||
<pre v-if="notification.messageJson" style="cursor: auto">{{ JSON.stringify(notification.messageJson, null, 4) }}</pre>
|
||||
<div v-else style="cursor: auto; overflow: auto;" v-html="marked.parse(notification.message)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="notifications.length" @click="onMarkAllNotificationRead()" :loading="notificationsAllBusy" :disabled="notificationsAllBusy">{{ $t('notifications.markAllAsRead') }}</Button>
|
||||
<div v-if="notifications.length === 0" class="notification-item-empty-placeholder">
|
||||
{{ $t('notifications.allCaughtUp') }}
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<Popover ref="helpPopover" :width="'min(80%, 400px)'" :height="'min(80%, 600px)'">
|
||||
<div style="padding: 10px; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
||||
<h1 class="help-title">{{ $t('support.help.title') }}</h1>
|
||||
@@ -199,7 +118,7 @@ onUnmounted(() => {
|
||||
<!-- Warnings if subscription is expired or unpaid -->
|
||||
<div v-if="profile.isAtLeastOwner && subscription.plan.id === 'expired'" class="headerbar-action subscription-expired" style="gap: 6px" @click="onSubscriptionRequired()">Subscription Expired</div>
|
||||
|
||||
<div class="headerbar-action" v-if="profile.isAtLeastAdmin" ref="notificationButton" @click="onOpenNotifications(notificationPopover, $event, notificationButton)"><Icon :icon="notifications.length ? 'fas fa-bell' : 'far fa-bell'"/> {{ notifications.length > 99 ? '99+' : notifications.length }}</div>
|
||||
<a class="headerbar-action" v-if="profile.isAtLeastAdmin" href="/#/notifications"><Icon :icon="notificationCount > 0 ? 'fas fa-bell' : 'far fa-bell'"/> {{ notificationCount > 99 ? '99+' : notificationCount }}</a>
|
||||
<div class="headerbar-action pankow-no-mobile" v-if="profile.isAtLeastAdmin" ref="helpButton" @click="onOpenHelp(helpPopover, $event, helpButton)"><Icon icon="fa fa-question"/></div>
|
||||
<!-- <a class="headerbar-action" v-if="profile.isAtLeastAdmin" href="#/support"><Icon icon="fa fa-question"/></a> -->
|
||||
<a class="headerbar-action" @click.capture="onAvatarClick($event)"><img :src="profile.avatarUrl" @error="event => event.target.src = '/img/avatar-default-symbolic.svg'"/> {{ profile.username }}</a>
|
||||
@@ -249,40 +168,6 @@ onUnmounted(() => {
|
||||
border-bottom: 1px solid var(--pankow-input-border-color);
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--pankow-input-border-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-item-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.notification-item-date {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.notification-read-button {
|
||||
visibility: hidden;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.notification-item:hover .notification-read-button {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.notification-item .notification-read-button {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.subscription-expired {
|
||||
background-color: var(--pankow-color-danger);
|
||||
color: white;
|
||||
|
||||
@@ -116,7 +116,7 @@ onMounted(async () => {
|
||||
<input style="display: none" type="submit"/>
|
||||
|
||||
<FormGroup>
|
||||
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/network/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name" required/>
|
||||
<div class="has-error" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -116,7 +116,7 @@ onMounted(async () => {
|
||||
<input style="display: none" type="submit" />
|
||||
|
||||
<FormGroup>
|
||||
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/network/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name" required/>
|
||||
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -62,7 +62,11 @@ onMounted(async () => {
|
||||
const crashId = urlParams.get('crashId');
|
||||
const idParam = urlParams.get('id');
|
||||
|
||||
if (appId) {
|
||||
if (appId && taskId) {
|
||||
type.value = 'task';
|
||||
id.value = taskId;
|
||||
name.value = 'Task ' + taskId;
|
||||
} else if (appId) {
|
||||
type.value = 'app';
|
||||
id.value = appId;
|
||||
name.value = 'App ' + appId;
|
||||
@@ -89,7 +93,7 @@ onMounted(async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
logsModel = LogsModel.create(type.value, id.value);
|
||||
logsModel = LogsModel.create(type.value, id.value, { appId });
|
||||
|
||||
if (type.value === 'app') {
|
||||
const [error, app] = await appsModel.get(id.value);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Button } from '@cloudron/pankow';
|
||||
import { Button, ClipboardAction } from '@cloudron/pankow';
|
||||
import Section from './Section.vue';
|
||||
import MailModel from '../models/MailModel.js';
|
||||
|
||||
@@ -103,11 +103,11 @@ onMounted(async () => {
|
||||
<div v-if="key === 'mx' && domain.provider === 'namecheap'">{{ $t('email.dnsStatus.namecheapInfo') }} <sup><a href="https://docs.cloudron.io/domains/#namecheap-dns" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></div>
|
||||
<div v-if="key === 'ptr4' || key === 'ptr6'">{{ $t('email.dnsStatus.ptrInfo') }} <sup><a href="https://docs.cloudron.io/email/#ptr-record" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></div>
|
||||
<div v-if="domainStatus[key].status === 'skipped'">{{ domainStatus[key].message }}</div>
|
||||
<div v-else>
|
||||
<table class="domain-status">
|
||||
<div v-else style="overflow: hidden;">
|
||||
<table class="domain-status" style="width: 100%; table-layout: fixed;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ $t('email.dnsStatus.hostname') }}:</td>
|
||||
<td style="width: 160px">{{ $t('email.dnsStatus.hostname') }}:</td>
|
||||
<td>{{ domainStatus[key].name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -119,12 +119,17 @@ onMounted(async () => {
|
||||
<td>{{ domainStatus[key].type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('email.dnsStatus.expected') }}:</td>
|
||||
<td>{{ domainStatus[key].expected }}</td>
|
||||
<td class="domain-status-expected-label">{{ $t('email.dnsStatus.expected') }}:</td>
|
||||
<td class="domain-status-expected-value">
|
||||
<div class="domain-status-expected">{{ domainStatus[key].expected }}</div>
|
||||
<ClipboardAction :value="domainStatus[key].expected"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('email.dnsStatus.current') }}:</td>
|
||||
<td>{{ domainStatus[key].value ? domainStatus[key].value : ('['+$t('email.dnsStatus.recordNotSet')+']') }} {{ domainStatus[key].message }}</td>
|
||||
<td>
|
||||
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ domainStatus[key].value ? domainStatus[key].value : ('['+$t('email.dnsStatus.recordNotSet')+']') }} {{ domainStatus[key].message }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -219,7 +224,7 @@ onMounted(async () => {
|
||||
overflow: scroll;
|
||||
white-space: nowrap;
|
||||
text-overflow: auto;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.domain-status > tbody > tr > td:first-of-type {
|
||||
@@ -227,4 +232,19 @@ onMounted(async () => {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.domain-status-expected-label {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.domain-status-expected-value {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.domain-status-expected {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
+39
-14
@@ -1,18 +1,19 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Switch } from '@cloudron/pankow';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Switch, Dialog } from '@cloudron/pankow';
|
||||
import SettingsItem from '../components/SettingsItem.vue';
|
||||
import Section from '../components/Section.vue';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const profileModel = ProfileModel.create();
|
||||
|
||||
const dialogItem = useTemplateRef('dialogItem');
|
||||
const busy = ref(false);
|
||||
const appUpp = ref(false);
|
||||
const appDown = ref(false);
|
||||
const appOutOfMemory = ref(false);
|
||||
const backupFailed = ref(false);
|
||||
const appAutoUpdateFailed = ref(false);
|
||||
const certificateRenewalFailed = ref(false);
|
||||
const diskSpace = ref(false);
|
||||
const cloudronUpdateFailed = ref(false);
|
||||
@@ -26,6 +27,7 @@ async function onSubmit() {
|
||||
if (appDown.value) config.push('appDown');
|
||||
if (appOutOfMemory.value) config.push('appOutOfMemory');
|
||||
if (backupFailed.value) config.push('backupFailed');
|
||||
if (appAutoUpdateFailed.value) config.push('appAutoUpdateFailed');
|
||||
if (certificateRenewalFailed.value) config.push('certificateRenewalFailed');
|
||||
if (diskSpace.value) config.push('diskSpace');
|
||||
if (cloudronUpdateFailed.value) config.push('cloudronUpdateFailed');
|
||||
@@ -34,10 +36,12 @@ async function onSubmit() {
|
||||
const [error] = await profileModel.setNotificationConfig(config);
|
||||
if (error) return console.error(error);
|
||||
|
||||
dialogItem.value.close();
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
async function open() {
|
||||
const [error, result] = await profileModel.get();
|
||||
if (error) return console.error(error);
|
||||
|
||||
@@ -47,58 +51,79 @@ onMounted(async () => {
|
||||
appDown.value = config.indexOf('appDown') !== -1;
|
||||
appOutOfMemory.value = config.indexOf('appOutOfMemory') !== -1;
|
||||
backupFailed.value = config.indexOf('backupFailed') !== -1;
|
||||
appAutoUpdateFailed.value = config.indexOf('appAutoUpdateFailed') !== -1;
|
||||
certificateRenewalFailed.value = config.indexOf('certificateRenewalFailed') !== -1;
|
||||
diskSpace.value = config.indexOf('diskSpace') !== -1;
|
||||
cloudronUpdateFailed.value = config.indexOf('cloudronUpdateFailed') !== -1;
|
||||
reboot.value = config.indexOf('reboot') !== -1;
|
||||
|
||||
dialogItem.value.open();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Section :title="$t('notifications.settings.title')">
|
||||
<Dialog ref="dialogItem"
|
||||
:title="$t('notifications.settings.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
confirm-style="primary"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
|
||||
<div>{{ $t('notifications.settingsDialog.description') }}</div>
|
||||
<br/>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.appUp') }}</div>
|
||||
<Switch v-model="appUpp" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="appUpp" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.appDown') }}</div>
|
||||
<Switch v-model="appDown" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="appDown" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.appOutOfMemory') }}</div>
|
||||
<Switch v-model="appOutOfMemory" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="appOutOfMemory" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.backupFailed') }}</div>
|
||||
<Switch v-model="backupFailed" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="backupFailed" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.appAutoUpdateFailed') }}</div>
|
||||
<Switch v-model="appAutoUpdateFailed" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.certificateRenewalFailed') }}</div>
|
||||
<Switch v-model="certificateRenewalFailed" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="certificateRenewalFailed" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.diskSpace') }}</div>
|
||||
<Switch v-model="diskSpace" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="diskSpace" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.cloudronUpdateFailed') }}</div>
|
||||
<Switch v-model="cloudronUpdateFailed" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="cloudronUpdateFailed" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.rebootRequired') }}</div>
|
||||
<Switch v-model="reboot" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="reboot" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
</Section>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { Dialog } from '@cloudron/pankow';
|
||||
import { stripSsoInfo } from '../utils.js';
|
||||
import { renderSafeMarkdown, stripSsoInfo } from '../utils.js';
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const app = ref(null);
|
||||
@@ -48,13 +47,13 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="app.manifest.postInstallMessage" v-html="marked.parse(stripSsoInfo(app.manifest.postInstallMessage, app.sso))"></div>
|
||||
<div v-if="app.manifest.postInstallMessage" v-html="renderSafeMarkdown(stripSsoInfo(app.manifest.postInstallMessage, app.sso))"></div>
|
||||
|
||||
<div class="app-info-checklist" v-show="hasPendingChecklistItems">
|
||||
<label class="control-label">{{ $t('app.appInfo.checklist') }}</label>
|
||||
<div v-for="(item, key) in app.checklist" :key="key">
|
||||
<div class="checklist-item" v-show="!item.acknowledged">
|
||||
<span v-html="marked.parse(item.message)"></span>
|
||||
<span v-html="renderSafeMarkdown(item.message)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@cloudron/pankow';
|
||||
|
||||
const visible = ref(false);
|
||||
const success = ref(false);
|
||||
const TIMEOUT = 1500;
|
||||
let timeoutId;
|
||||
|
||||
defineExpose({
|
||||
success() {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
success.value = true;
|
||||
visible.value = true;
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
visible.value = false;
|
||||
}, TIMEOUT);
|
||||
},
|
||||
error() {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
success.value = false;
|
||||
visible.value = true;
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
visible.value = false;
|
||||
}, TIMEOUT);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="bounce">
|
||||
<div class="save-indicator" v-if="visible" :class="{ success: success, error: !success }"><Icon :icon="success ? 'fa-solid fa-check' : 'fa-solid fa-xmark'"/></div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.save-indicator {
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
.save-indicator.success {
|
||||
color: var(--pankow-color-success);
|
||||
}
|
||||
|
||||
.save-indicator.error {
|
||||
color: var(--pankow-color-danger);
|
||||
}
|
||||
|
||||
.bounce-enter-active {
|
||||
animation: bounce-in 0.5s;
|
||||
}
|
||||
|
||||
.bounce-leave-active {
|
||||
animation: bounce-in 0.5s reverse;
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -79,7 +79,6 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-header-title-text {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { marked } from 'marked';
|
||||
import { Button, PasswordInput, FormGroup, TextInput } from '@cloudron/pankow';
|
||||
import PublicPageLayout from '../components/PublicPageLayout.vue';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
import { startAuthFlow } from '../utils.js';
|
||||
|
||||
const profileModel = ProfileModel.create();
|
||||
|
||||
@@ -89,7 +90,7 @@ async function onSubmit() {
|
||||
// set token to autologin on first oidc flow
|
||||
localStorage.cloudronFirstTimeToken = result.accessToken;
|
||||
|
||||
dashboardUrl.value = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
|
||||
dashboardUrl.value = await startAuthFlow('cid-webadmin', '');
|
||||
|
||||
busy.value = false;
|
||||
mode.value = MODE.DONE;
|
||||
|
||||
@@ -199,7 +199,7 @@ function onBackdrop(event) {
|
||||
.sidebar-item-header {
|
||||
background-color: #e9ecef;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
color: var(--pankow-text-color);
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
@@ -224,7 +224,7 @@ function onBackdrop(event) {
|
||||
.sidebar-item.active {
|
||||
color: var(--pankow-color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
|
||||
@@ -7,14 +7,14 @@ defineProps({
|
||||
},
|
||||
state: {
|
||||
validator(value) {
|
||||
// The value must match one of these strings
|
||||
return ['success', 'warning', 'danger', ''].includes(value);
|
||||
return ['success', 'warning', 'danger', 'idle', ''].includes(value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function color(state) {
|
||||
if (state === 'success') return '#27CE65';
|
||||
else if (state === 'idle') return '#BCD0C3';
|
||||
else if (state === 'warning') return '#f0ad4e';
|
||||
else if (state === 'danger') return '#d9534f';
|
||||
else return '#7c7c7c';
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, FormGroup, TextInput, Checkbox, TableView, Dialog } from '@cloudron/pankow';
|
||||
import { ref, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import { Button, FormGroup, TextInput, Checkbox, TableView, Dialog, Spinner } from '@cloudron/pankow';
|
||||
import { prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import { TASK_TYPES } from '../constants.js';
|
||||
import ActionBar from '../components/ActionBar.vue';
|
||||
@@ -50,6 +50,12 @@ const columns = {
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
},
|
||||
integrity: {
|
||||
label: 'Integrity',
|
||||
sort: false,
|
||||
width: '100px',
|
||||
align: 'center',
|
||||
},
|
||||
actions: {}
|
||||
};
|
||||
|
||||
@@ -66,6 +72,12 @@ function createActionMenu(backup) {
|
||||
icon: 'fa-solid fa-file-alt',
|
||||
label: t('backups.listing.tooltipDownloadBackupConfig'),
|
||||
action: onDownloadConfig.bind(null, backup),
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-key',
|
||||
label: backup.integrityCheckTask?.active ? t('backups.stopIntegrity') : t('backups.checkIntegrity'),
|
||||
action: backup.integrityCheckTask?.active ? onStopIntegrityCheck.bind(null, backup) : onStartIntegrityCheck.bind(null, backup),
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -151,6 +163,18 @@ async function refreshTasks() {
|
||||
});
|
||||
}
|
||||
|
||||
const INTEGRITY_POLL_INTERVAL_MS = 5000;
|
||||
let integrityPollTimer = null;
|
||||
|
||||
function scheduleIntegrityPoll() {
|
||||
if (integrityPollTimer) return;
|
||||
integrityPollTimer = setTimeout(async () => {
|
||||
integrityPollTimer = null;
|
||||
await refreshBackups();
|
||||
if (backups.value.some(b => b.integrityCheckTask?.active)) scheduleIntegrityPoll();
|
||||
}, INTEGRITY_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function refreshBackups() {
|
||||
const [error, result] = await backupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
@@ -161,6 +185,20 @@ async function refreshBackups() {
|
||||
});
|
||||
|
||||
backups.value = result;
|
||||
|
||||
if (result.some(b => b.integrityCheckTask?.active)) scheduleIntegrityPoll();
|
||||
}
|
||||
|
||||
async function onStartIntegrityCheck(backup) {
|
||||
const [error] = await backupsModel.startIntegrityCheck(backup.id);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
await refreshBackups();
|
||||
}
|
||||
|
||||
async function onStopIntegrityCheck(backup) {
|
||||
const [error] = await backupsModel.stopIntegrityCheck(backup.id);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
await refreshBackups();
|
||||
}
|
||||
|
||||
async function refreshBackupSites() {
|
||||
@@ -231,6 +269,10 @@ onMounted(async () => {
|
||||
await refreshTasks();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (integrityPollTimer) clearTimeout(integrityPollTimer);
|
||||
});
|
||||
|
||||
defineExpose({ refresh });
|
||||
|
||||
</script>
|
||||
@@ -272,7 +314,7 @@ defineExpose({ refresh });
|
||||
</template>
|
||||
|
||||
<TableView :columns="columns" :model="backups" :busy="busy" :placeholder="$t('backups.listing.noBackups')">
|
||||
<template #creationTime="backup">
|
||||
<template #creationTime="{ item:backup }">
|
||||
<div>
|
||||
<span>{{ prettyLongDate(backup.creationTime) }}</span>
|
||||
<span v-if="backup.label"> <b>{{ backup.label }}</b></span>
|
||||
@@ -280,18 +322,26 @@ defineExpose({ refresh });
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content="backup">
|
||||
<template #content="{ item:backup }">
|
||||
<span v-if="backup.appCount">{{ $t('backups.listing.appCount', { appCount: backup.appCount }) }}</span>
|
||||
<span v-else>{{ $t('backups.listing.noApps') }}</span>
|
||||
</template>
|
||||
|
||||
<template #size="backup">
|
||||
<template #size="{ item:backup }">
|
||||
<span v-if="backup.stats?.aggregatedUpload">{{ prettyFileSize(backup.stats.aggregatedUpload.size) }} | {{ backup.stats.aggregatedUpload.fileCount }} file(s)</span>
|
||||
</template>
|
||||
|
||||
<template #site="backup">{{ backup.site.name }}</template>
|
||||
<template #site="{ item:backup }">{{ backup.site.name }}</template>
|
||||
|
||||
<template #actions="backup">
|
||||
<template #integrity="{ item:backup }">
|
||||
<Spinner v-if="backup.integrityCheckTask?.active" style="min-width: 0;"/>
|
||||
<div v-else-if="backup.lastIntegrityCheckTime" style="display: flex; align-items: center; justify-content: center;">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(backup.lastIntegrityCheckTime)"></i>
|
||||
</div>
|
||||
<div v-else style="text-align: center;">-</div>
|
||||
</template>
|
||||
|
||||
<template #actions="{ item:backup }">
|
||||
<ActionBar :actions="createActionMenu(backup)"/>
|
||||
</template>
|
||||
</TableView>
|
||||
|
||||
@@ -30,7 +30,8 @@ const taskLogsMenu = ref([]);
|
||||
const apps = ref([]);
|
||||
const version = ref('');
|
||||
const ubuntuVersion = ref('');
|
||||
const currentPattern = ref('');
|
||||
const currentSchedule = ref('');
|
||||
const currentPolicy = ref('');
|
||||
const updateBusy = ref(false);
|
||||
const updateError = ref({});
|
||||
const stopError = ref({});
|
||||
@@ -55,17 +56,16 @@ const inProgressApps = computed(() => {
|
||||
const configureDialog = useTemplateRef('configureDialog');
|
||||
const configureBusy = ref(false);
|
||||
const configureError = ref('');
|
||||
const configureType = ref('');
|
||||
const configurePattern = ref('');
|
||||
const configurePolicy = ref('');
|
||||
const configureDays = ref([]);
|
||||
const configureHours = ref([]);
|
||||
|
||||
async function refreshAutoupdatePattern() {
|
||||
const [error, result] = await updaterModel.getAutoupdatePattern();
|
||||
async function refreshAutoupdateConfig() {
|
||||
const [error, result] = await updaterModel.getAutoupdateConfig();
|
||||
if (error) return console.error(error);
|
||||
|
||||
currentPattern.value = result.pattern;
|
||||
configurePattern.value = result.pattern;
|
||||
currentSchedule.value = result.schedule;
|
||||
currentPolicy.value = result.policy;
|
||||
}
|
||||
|
||||
async function refreshApps() {
|
||||
@@ -87,21 +87,22 @@ async function refreshPendingUpdateInfo() {
|
||||
}
|
||||
|
||||
function onShowConfigure() {
|
||||
if (currentPattern.value === 'never') {
|
||||
configureType.value = 'never';
|
||||
} else {
|
||||
configureType.value = 'pattern';
|
||||
const result = parseSchedule(currentPattern.value);
|
||||
configureDays.value = result.days; // Array of cronDays.id
|
||||
configureHours.value = result.hours; // Array of cronHours.id
|
||||
configurePolicy.value = currentPolicy.value || 'never';
|
||||
|
||||
if (currentPolicy.value !== 'never') {
|
||||
const result = parseSchedule(currentSchedule.value);
|
||||
configureDays.value = result.days;
|
||||
configureHours.value = result.hours;
|
||||
}
|
||||
|
||||
configureDialog.value.open();
|
||||
}
|
||||
|
||||
async function onSubmitConfigure() {
|
||||
let pattern = 'never';
|
||||
if (configureType.value === 'pattern') {
|
||||
let schedule = currentSchedule.value || '00 00 1,3,5,23 * * *';
|
||||
const policy = configurePolicy.value;
|
||||
|
||||
if (policy !== 'never') {
|
||||
let daysPattern;
|
||||
if (configureDays.value.length === 7) daysPattern = '*';
|
||||
else daysPattern = configureDays.value.join(',');
|
||||
@@ -110,18 +111,18 @@ async function onSubmitConfigure() {
|
||||
if (configureHours.value.length === 24) hoursPattern = '*';
|
||||
else hoursPattern = configureHours.value.join(',');
|
||||
|
||||
pattern ='00 00 ' + hoursPattern + ' * * ' + daysPattern;
|
||||
schedule = '00 00 ' + hoursPattern + ' * * ' + daysPattern;
|
||||
}
|
||||
|
||||
configureBusy.value = true;
|
||||
const [error] = await updaterModel.setAutoupdatePattern(pattern);
|
||||
const [error] = await updaterModel.setAutoupdateConfig(schedule, policy);
|
||||
if (error) {
|
||||
configureError.value = error.body ? error.body.message : 'Internal error';
|
||||
configureBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await refreshAutoupdatePattern();
|
||||
await refreshAutoupdateConfig();
|
||||
|
||||
configureBusy.value = false;
|
||||
configureDialog.value.close();
|
||||
@@ -239,7 +240,7 @@ onMounted(async () => {
|
||||
ubuntuVersion.value = result.ubuntuVersion;
|
||||
|
||||
await refreshPendingUpdateInfo();
|
||||
await refreshAutoupdatePattern();
|
||||
await refreshAutoupdateConfig();
|
||||
await refreshTasks();
|
||||
|
||||
ready.value = true;
|
||||
@@ -288,25 +289,35 @@ onMounted(async () => {
|
||||
</Dialog>
|
||||
|
||||
<Dialog ref="configureDialog"
|
||||
:title="$t('settings.updateScheduleDialog.title')"
|
||||
:title="$t('settings.configureUpdates.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-active="configureType === 'never' ? true : (configureHours.length > 0 && configureDays.length > 0)"
|
||||
:confirm-active="configurePolicy === 'never' ? true : (configureHours.length > 0 && configureDays.length > 0)"
|
||||
:confirm-busy="configureBusy"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!configureBusy"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmitConfigure()"
|
||||
>
|
||||
<FormGroup>
|
||||
<div description v-html="$t('settings.updateScheduleDialog.description')"></div>
|
||||
|
||||
<p class="has-error text-center" v-show="configureError">{{ configureError }}</p>
|
||||
|
||||
<Radiobutton v-model="configureType" value="never" :label="$t('settings.updateScheduleDialog.disableCheckbox')" />
|
||||
<Radiobutton v-model="configureType" value="pattern" :label="$t('settings.updateScheduleDialog.enableCheckbox')"/>
|
||||
<label>{{ $t('settings.configureUpdates.policy') }}</label>
|
||||
<div>{{ $t('settings.configureUpdates.policyDescription') }}</div>
|
||||
<div style="padding-top: 10px">
|
||||
<Radiobutton v-model="configurePolicy" value="never" :label="$t('settings.updates.disabled')" />
|
||||
<Radiobutton v-model="configurePolicy" value="apps_only" :label="$t('settings.updates.appsOnly')" />
|
||||
<Radiobutton v-model="configurePolicy" value="platform_and_apps" :label="$t('settings.updates.platformAndApps')" />
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<div v-show="configureType === 'pattern'" style="display: flex; gap: 10px; align-items: center; margin: 10px 0px 0px 25px">
|
||||
<div>{{ $t('settings.updateScheduleDialog.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="id"/></div>
|
||||
<div>{{ $t('settings.updateScheduleDialog.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="id"/></div>
|
||||
<div class="text-small text-danger" v-show="configureType === 'pattern' && !(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
|
||||
<FormGroup>
|
||||
<div v-show="configurePolicy !== 'never'">
|
||||
<label>{{ $t('settings.configureUpdates.schedule') }}</label>
|
||||
<div style="display: flex; gap: 10px; align-items: center; margin-top: 12px">
|
||||
<div>{{ $t('settings.configureUpdates.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="id"/></div>
|
||||
<div>{{ $t('settings.configureUpdates.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="id"/></div>
|
||||
<div class="text-small text-danger" v-show="!(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</Dialog>
|
||||
@@ -321,9 +332,10 @@ onMounted(async () => {
|
||||
|
||||
<SettingsItem v-if="ready">
|
||||
<div>
|
||||
<label>{{ $t('settings.updates.schedule') }}</label>
|
||||
<span v-if="currentPattern !== 'never'">{{ prettySchedule(currentPattern) }}</span>
|
||||
<span v-else>{{ $t('settings.updates.disabled') }}</span>
|
||||
<label>{{ $t('settings.updates.config') }}</label>
|
||||
<span v-if="currentPolicy === 'never'">{{ $t('settings.updates.disabled') }}</span>
|
||||
<span v-else-if="currentPolicy === 'apps_only'">{{ $t('settings.updates.appsOnly') }} - {{ prettySchedule(currentSchedule) }}</span>
|
||||
<span v-else>{{ $t('settings.updates.platformAndApps') }} - {{ prettySchedule(currentSchedule) }}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center">
|
||||
<Button tool plain @click="onShowConfigure()">{{ $t('main.dialog.edit') }}</Button>
|
||||
|
||||
@@ -28,6 +28,7 @@ const showFilemanager = ref(false);
|
||||
const manifestVersion = ref('');
|
||||
const schedulerMenuModel = ref([]);
|
||||
const id = ref('');
|
||||
const cwd = ref('');
|
||||
const name = ref('');
|
||||
const link = ref('');
|
||||
const downloadFileDownloadUrl = ref('');
|
||||
@@ -165,7 +166,9 @@ async function connect(retry = false) {
|
||||
|
||||
let execId;
|
||||
try {
|
||||
const result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id.value}/exec`, { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' }, { access_token: accessToken });
|
||||
const execBody = { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' };
|
||||
if (cwd.value) execBody.cwd = cwd.value;
|
||||
const result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id.value}/exec`, execBody, { access_token: accessToken });
|
||||
execId = result.body.id;
|
||||
} catch (error) {
|
||||
console.error('Cannot create socket.', error);
|
||||
@@ -216,6 +219,7 @@ onMounted(async () => {
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
id.value = urlParams.get('id');
|
||||
cwd.value = urlParams.get('cwd') || '';
|
||||
|
||||
if (!id.value) {
|
||||
console.error('No app id specified');
|
||||
|
||||
@@ -72,7 +72,7 @@ async function onSubmit() {
|
||||
let userId = user.value ? user.value.id : null;
|
||||
|
||||
// can only be set not updated
|
||||
if (!user.value || !user.value.username) data.username = username.value || null;
|
||||
if ((!user.value || !user.value.username) && username.value) data.username = username.value;
|
||||
|
||||
const isExternal = user.value && user.value.source;
|
||||
|
||||
@@ -241,15 +241,15 @@ defineExpose({
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<!-- if profile edit is locked a username has to be set here . username is editable if none is set -->
|
||||
<FormGroup :has-error="formError.username">
|
||||
<FormGroup :has-error="!!formError.username">
|
||||
<label for="usernameInput">{{ $t('users.user.username') }}</label>
|
||||
<TextInput id="usernameInput" v-model="username" :required="!user?.username && profileLocked" :readonly="user?.username ? true : false" />
|
||||
<small v-if="!user?.username && !profileLocked" class="helper-text">{{ $t('users.user.usernamePlaceholder') }}</small>
|
||||
<div class="error-label" v-if="formError.username">{{ formError.username }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="emailInput" :has-error="formError.email">{{ $t('users.user.primaryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<FormGroup :has-error="!!formError.email">
|
||||
<label for="emailInput">{{ $t('users.user.primaryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<EmailInput id="emailInput" v-model="email" :readonly="user?.source ? true : false" :required="user?.source ? false : true" />
|
||||
<div class="error-label" v-if="formError.email">{{ formError.email }}</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -15,6 +15,8 @@ const domain = ref('');
|
||||
const matrixHostname = ref('');
|
||||
const mastodonHostname = ref('');
|
||||
const jitsiHostname = ref('');
|
||||
const carddavLocation = ref('');
|
||||
const caldavLocation = ref('');
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
@@ -47,6 +49,9 @@ async function onSubmit() {
|
||||
+ '</XRD>';
|
||||
}
|
||||
|
||||
if (carddavLocation.value) wellKnown['carddav'] = carddavLocation.value;
|
||||
if (caldavLocation.value) wellKnown['caldav'] = caldavLocation.value;
|
||||
|
||||
const [error] = await domainsModel.setWellKnown(domain.value, wellKnown);
|
||||
if (error) {
|
||||
errorMessage.value = error.body ? error.body.message : 'Internal error';
|
||||
@@ -66,19 +71,21 @@ defineExpose({
|
||||
matrixHostname.value = '';
|
||||
mastodonHostname.value = '';
|
||||
jitsiHostname.value = '';
|
||||
caldavLocation.value = '';
|
||||
carddavLocation.value = '';
|
||||
|
||||
try {
|
||||
if (d.wellKnown && d.wellKnown['matrix/server']) {
|
||||
matrixHostname.value = JSON.parse(d.wellKnown['matrix/server'])['m.server'];
|
||||
}
|
||||
if (d.wellKnown && d.wellKnown['host-meta']) {
|
||||
mastodonHostname.value = d.wellKnown['host-meta'].match(new RegExp('template="https://(.*?)/'))[1];
|
||||
}
|
||||
if (d.wellKnown && d.wellKnown['matrix/client']) {
|
||||
const parsed = JSON.parse(d.wellKnown['matrix/client']);
|
||||
if (parsed['im.vector.riot.jitsi'] && parsed['im.vector.riot.jitsi']['preferredDomain']) {
|
||||
jitsiHostname.value = parsed['im.vector.riot.jitsi']['preferredDomain'];
|
||||
if (d.wellKnown) {
|
||||
if (d.wellKnown['matrix/server']) matrixHostname.value = JSON.parse(d.wellKnown['matrix/server'])['m.server'];
|
||||
if (d.wellKnown['host-meta']) mastodonHostname.value = d.wellKnown['host-meta'].match(new RegExp('template="https://(.*?)/'))[1];
|
||||
if (d.wellKnown['matrix/client']) {
|
||||
const parsed = JSON.parse(d.wellKnown['matrix/client']);
|
||||
if (parsed['im.vector.riot.jitsi'] && parsed['im.vector.riot.jitsi']['preferredDomain']) {
|
||||
jitsiHostname.value = parsed['im.vector.riot.jitsi']['preferredDomain'];
|
||||
}
|
||||
}
|
||||
if (d.wellKnown['carddav']) carddavLocation.value = d.wellKnown['carddav'];
|
||||
if (d.wellKnown['caldav']) caldavLocation.value = d.wellKnown['caldav'];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -110,6 +117,16 @@ defineExpose({
|
||||
|
||||
<p class="has-error" v-show="errorMessage">{{ errorMessage }}</p>
|
||||
|
||||
<FormGroup>
|
||||
<label for="">{{ $t('domains.domainDialog.carddavLocation') }}</label>
|
||||
<TextInput id="" v-model="carddavLocation" placeholder="contacts.example.com"/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="">{{ $t('domains.domainDialog.caldavLocation') }}</label>
|
||||
<TextInput id="" v-model="caldavLocation" placeholder="calendar.example.com"/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="">{{ $t('domains.domainDialog.matrixHostname') }}</label>
|
||||
<TextInput id="" v-model="matrixHostname" placeholder="synapse.example.com:443"/>
|
||||
|
||||
@@ -56,6 +56,7 @@ onMounted(async () => {
|
||||
u.username = u.username || u.email; // ensure username
|
||||
userIds.add(u.id);
|
||||
}
|
||||
result.forEach(u => { u.label = u.username || u.email; });
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
@@ -90,7 +91,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!loading">
|
||||
<div v-show="!loading">
|
||||
<div class="text-danger" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :users="users" :groups="groups" :manifest="app.manifest" :sso="app.sso" :installation="false"/>
|
||||
<div style="padding-top: 10px"></div>
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, Switch, Checkbox, FormGroup, TextInput, TableView, Dialog, ProgressBar } from '@cloudron/pankow';
|
||||
import { ref, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import { Button, Switch, Checkbox, FormGroup, TextInput, TableView, Dialog, ProgressBar, Spinner } from '@cloudron/pankow';
|
||||
import { prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import { API_ORIGIN, RSTATES } from '../../constants.js';
|
||||
import { download } from '../../utils.js';
|
||||
@@ -14,14 +14,13 @@ import AppRestoreDialog from '../AppRestoreDialog.vue';
|
||||
import SettingsItem from '../SettingsItem.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import BackupSitesModel from '../../models/BackupSitesModel.js';
|
||||
import TasksModel from '../../models/TasksModel.js';
|
||||
import { TASK_TYPES } from '../../constants.js';
|
||||
import BackupsModel from '../../models/BackupsModel.js';
|
||||
import BackupInfoDialog from '../BackupInfoDialog.vue';
|
||||
import ActionBar from '../../components/ActionBar.vue';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
const backupsModel = BackupsModel.create();
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
|
||||
@@ -47,12 +46,21 @@ const columns = ref({
|
||||
label: t('main.table.version'),
|
||||
sort: true,
|
||||
},
|
||||
integrity: {
|
||||
label: 'Integrity',
|
||||
sort: false,
|
||||
width: '100px',
|
||||
align: 'center',
|
||||
},
|
||||
actions: {
|
||||
label: '',
|
||||
sort: false,
|
||||
width: '100px',
|
||||
}
|
||||
});
|
||||
|
||||
const accessLevel = props.app.accessLevel;
|
||||
|
||||
function createActionMenu(backup) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-info',
|
||||
@@ -61,27 +69,27 @@ function createActionMenu(backup) {
|
||||
}, {
|
||||
icon: 'fa-solid fa-pencil-alt',
|
||||
label: t('main.action.edit'),
|
||||
visible: props.app.accessLevel === 'admin',
|
||||
visible: accessLevel === 'admin',
|
||||
action: onEdit.bind(null, backup),
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-download',
|
||||
label: t('app.backups.backups.downloadBackupTooltip'),
|
||||
visible: backup.site.format === 'tgz' && props.app.accessLevel === 'admin',
|
||||
visible: backup.site.format === 'tgz' && accessLevel === 'admin',
|
||||
href: getDownloadLink(backup),
|
||||
}, {
|
||||
icon: 'fa-solid fa-file-alt',
|
||||
label: t('app.backups.backups.downloadConfigTooltip'),
|
||||
visible: props.app.accessLevel === 'admin',
|
||||
visible: accessLevel === 'admin',
|
||||
action: onDownloadConfig.bind(null, backup),
|
||||
}, {
|
||||
separator: true,
|
||||
visible: props.app.accessLevel === 'admin',
|
||||
visible: accessLevel === 'admin',
|
||||
}, {
|
||||
icon: 'fa-solid fa-clone',
|
||||
label: t('app.backups.backups.cloneTooltip'),
|
||||
visible: props.app.accessLevel === 'admin',
|
||||
visible: accessLevel === 'admin',
|
||||
action: onClone.bind(null, backup),
|
||||
}, {
|
||||
icon: 'fa-solid fa-history',
|
||||
@@ -89,13 +97,13 @@ function createActionMenu(backup) {
|
||||
disabled: !!props.app.taskId || props.app.runState === 'stopped',
|
||||
action: onRestore.bind(null, backup),
|
||||
quickAction: true
|
||||
// }, {
|
||||
// separator: true,
|
||||
// }, {
|
||||
// icon: 'fa-solid fa-key',
|
||||
// label: t('app.backups.backups.checkIntegrity'),
|
||||
// visible: props.app.accessLevel === 'admin',
|
||||
// action: onCheckIntegrity.bind(null, backup),
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-key',
|
||||
label: backup.integrityCheckTask?.active ? t('backups.stopIntegrity') : t('backups.checkIntegrity'),
|
||||
visible: accessLevel === 'admin',
|
||||
action: backup.integrityCheckTask?.active ? onStopIntegrityCheck.bind(null, backup) : onStartIntegrityCheck.bind(null, backup),
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -130,7 +138,7 @@ async function onChangeAutoBackups(value) {
|
||||
async function waitForTask() {
|
||||
if (!lastTask.value.id) return;
|
||||
|
||||
const [error, result] = await tasksModel.get(lastTask.value.id);
|
||||
const [error, result] = await appsModel.getAppTask(props.app.id, lastTask.value.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
lastTask.value = result;
|
||||
@@ -147,7 +155,7 @@ async function waitForTask() {
|
||||
}
|
||||
|
||||
async function refreshTasks() {
|
||||
const [error, result] = await tasksModel.getByType(TASK_TYPES.TASK_APP_BACKUP_PREFIX + props.app.id);
|
||||
const [error, result] = await appsModel.listTasks(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
lastTask.value = result[0] || {};
|
||||
@@ -157,7 +165,7 @@ async function refreshTasks() {
|
||||
return {
|
||||
icon: 'fa-solid ' + ((!t.active && t.success) ? 'status-active fa-check-circle' : (t.active ? 'fa-circle-notch fa-spin' : 'status-error fa-times-circle')),
|
||||
label: prettyLongDate(t.ts),
|
||||
action: () => { window.open(`/logs.html?taskId=${t.id}`); }
|
||||
action: () => { window.open(`/logs.html?appId=${props.app.id}&taskId=${t.id}`); }
|
||||
};
|
||||
});
|
||||
|
||||
@@ -177,7 +185,7 @@ async function onStartBackup(backupSiteId) {
|
||||
async function onStopBackup() {
|
||||
stopBackupBusy.value = true;
|
||||
|
||||
const [error] = await tasksModel.stop(lastTask.value.id);
|
||||
const [error] = await appsModel.stopAppTask(props.app.id, lastTask.value.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
await refreshTasks();
|
||||
@@ -232,11 +240,17 @@ async function onRestore(backup) {
|
||||
restoreDialog.value.open();
|
||||
}
|
||||
|
||||
// const backupsModel = BackupsModel.create();
|
||||
async function onStartIntegrityCheck(backup) {
|
||||
const [error] = await backupsModel.startIntegrityCheck(backup.id);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
await refreshBackupList();
|
||||
}
|
||||
|
||||
// async function onCheckIntegrity(backup) {
|
||||
// await backupsModel.checkIntegrity(backup.id);
|
||||
// }
|
||||
async function onStopIntegrityCheck(backup) {
|
||||
const [error] = await backupsModel.stopIntegrityCheck(backup.id);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
await refreshBackupList();
|
||||
}
|
||||
|
||||
async function onRestoreSubmit() {
|
||||
restoreBusy.value = true;
|
||||
@@ -260,14 +274,32 @@ function onClone(backup) {
|
||||
cloneDialog.value.open(backup, props.app.id);
|
||||
}
|
||||
|
||||
const INTEGRITY_POLL_INTERVAL_MS = 5000;
|
||||
let integrityPollTimer = null;
|
||||
|
||||
function scheduleIntegrityPoll() {
|
||||
if (integrityPollTimer) return;
|
||||
integrityPollTimer = setTimeout(async () => {
|
||||
integrityPollTimer = null;
|
||||
await refreshBackupList();
|
||||
if (backups.value.some(b => b.integrityCheckTask?.active)) {
|
||||
scheduleIntegrityPoll();
|
||||
}
|
||||
}, INTEGRITY_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function refreshBackupList() {
|
||||
const [error, result] = await appsModel.backups(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
result.forEach(backup => {
|
||||
for (const backup of result) {
|
||||
backup.site = backupSites.value.find(t => t.id === backup.siteId);
|
||||
});
|
||||
}
|
||||
backups.value = result;
|
||||
|
||||
if (result.some(b => b.integrityCheckTask?.active)) {
|
||||
scheduleIntegrityPoll();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -290,6 +322,10 @@ onMounted(async () => {
|
||||
busy.value = false;
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (integrityPollTimer) clearTimeout(integrityPollTimer);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -374,7 +410,7 @@ onMounted(async () => {
|
||||
<div style="margin-top: 10px; display: flex; align-items: center; gap: 10px; overflow: hidden;">
|
||||
<div style="flex-grow: 1; overflow: hidden;">
|
||||
<ProgressBar :value="lastTask.percent" :show-label="false" :busy="true" :mode="lastTask.percent <= 0 ? 'indeterminate' : ''"/>
|
||||
<a :href="`/logs.html?taskId=${lastTask.id}`" target="_blank" style="display: block; margin-top: 3px; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ lastTask.percent }}% {{ lastTask.message }}</a>
|
||||
<a :href="`/logs.html?appId=${props.app.id}&taskId=${lastTask.id}`" target="_blank" style="display: block; margin-top: 3px; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ lastTask.percent }}% {{ lastTask.message }}</a>
|
||||
</div>
|
||||
<Button danger plain tool icon="fa-solid fa-xmark" @click="onStopBackup()" :loading="stopBackupBusy" :disabled="stopBackupBusy"></Button>
|
||||
</div>
|
||||
@@ -387,23 +423,28 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :model="backups" :columns="columns" :busy="busy" :placeholder="$t('backups.listing.noBackups')" style="max-height: 400px;" >
|
||||
<template #creationTime="backup">
|
||||
<template #creationTime="{ item }">
|
||||
<div>
|
||||
<span>{{ prettyLongDate(backup.creationTime) }}</span>
|
||||
<span v-if="backup.label"> <b>{{ backup.label }}</b></span>
|
||||
<span> <i class="fa-solid fa-thumbtack text-muted" v-show="backup.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i></span>
|
||||
<span>{{ prettyLongDate(item.creationTime) }}</span>
|
||||
<span v-if="item.label"> <b>{{ item.label }}</b></span>
|
||||
<span> <i class="fa-solid fa-thumbtack text-muted" v-show="item.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #site="backup">
|
||||
{{ backup.site.name }}
|
||||
<template #site="{ item }">
|
||||
{{ item.site.name }}
|
||||
</template>
|
||||
<template #size="backup">
|
||||
<span v-if="backup.stats?.upload">{{ prettyFileSize(backup.stats.upload.size) }} - {{ backup.stats.upload.fileCount }} file(s)</span>
|
||||
<template #size="{ item }">
|
||||
<span v-if="item.stats?.upload">{{ prettyFileSize(item.stats.upload.size) }} - {{ item.stats.upload.fileCount }} file(s)</span>
|
||||
</template>
|
||||
<template #actions="backup">
|
||||
<div style="text-align: right;">
|
||||
<ActionBar style="width: 100px" :actions="createActionMenu(backup)"/>
|
||||
<template #integrity="{ item }">
|
||||
<Spinner v-if="item.integrityCheckTask?.active" style="min-width: 0;"/>
|
||||
<div v-else-if="item.lastIntegrityCheckTime" style="display: flex; align-items: center; justify-content: center;">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': item.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': item.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': item.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(item.lastIntegrityCheckTime)"></i>
|
||||
</div>
|
||||
<div v-else style="text-align: center;">-</div>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<ActionBar :actions="createActionMenu(item)"/>
|
||||
</template>
|
||||
</TableView>
|
||||
</div>
|
||||
|
||||
@@ -1,144 +1,37 @@
|
||||
<script setup>
|
||||
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { eventlogSource, eventlogDetails } from '../../utils.js';
|
||||
import EventlogList from '../EventlogList.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import { EVENTS } from '../../constants.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const busy = ref(true);
|
||||
|
||||
const eventlogs = ref([]);
|
||||
const availableActions = [
|
||||
{ id: EVENTS.APP_BACKUP, label: 'Backup started' },
|
||||
{ id: EVENTS.APP_BACKUP_FINISH, label: 'Backup finished' },
|
||||
{ id: EVENTS.APP_CONFIGURE, label: 'Reconfigured' },
|
||||
{ id: EVENTS.APP_INSTALL, label: 'Installed' },
|
||||
{ id: EVENTS.APP_RESTORE, label: 'Restored' },
|
||||
{ id: EVENTS.APP_UNINSTALL, label: 'Uninstalled' },
|
||||
{ id: EVENTS.APP_UPDATE, label: 'Update started' },
|
||||
{ id: EVENTS.APP_UPDATE_FINISH, label: 'Update finished' },
|
||||
{ id: EVENTS.APP_LOGIN, label: 'Log in' },
|
||||
{ id: EVENTS.APP_OOM, label: 'Out of memory' },
|
||||
{ id: EVENTS.APP_DOWN, label: 'Down' },
|
||||
{ id: EVENTS.APP_UP, label: 'Up' },
|
||||
{ id: EVENTS.APP_START, label: 'Started' },
|
||||
{ id: EVENTS.APP_STOP, label: 'Stopped' },
|
||||
{ id: EVENTS.APP_RESTART, label: 'Restarted' },
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await appsModel.getEvents(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = result.map(e => {
|
||||
return {
|
||||
id: Symbol(),
|
||||
raw: e,
|
||||
details: eventlogDetails(e, props.app),
|
||||
source: eventlogSource(e, props.app),
|
||||
};
|
||||
});
|
||||
|
||||
busy.value = false;
|
||||
});
|
||||
async function fetchPage(filter, page, perPage) {
|
||||
return appsModel.getEvents(props.app.id, filter, page, perPage);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="eventlog-list pankow-no-desktop">
|
||||
<div class="eventlog-list-item" v-for="eventlog in eventlogs" :key="eventlog.id" :class="{ 'active': eventlog.isOpen }">
|
||||
<div @click="eventlog.isOpen = !eventlog.isOpen" style="display: flex; justify-content: space-between; padding: 0 10px" >
|
||||
<div style="white-space: nowrap;">
|
||||
{{ prettyLongDate(eventlog.raw.creationTime) }}
|
||||
<b style="margin-left: 10px">{{ eventlog.raw.action }}</b>
|
||||
</div>
|
||||
<div>{{ eventlog.source }}</div>
|
||||
</div>
|
||||
<div v-show="eventlog.isOpen">
|
||||
<div class="eventlog-details" style="margin-top: 10px; padding-top: 5px">
|
||||
<div v-if="eventlog.raw.source.ip" class="eventlog-source">Source IP: <span @click="onCopySource(eventlog)">{{ eventlog.raw.source.ip }}</span></div>
|
||||
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="eventlog-table pankow-no-mobile">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 160px">{{ $t('eventlog.time') }}</th>
|
||||
<th style="width: 15%">{{ $t('eventlog.source') }}</th>
|
||||
<th style="word-break: break-all; overflow-wrap: anywhere;">{{ $t('eventlog.details') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="eventlog in eventlogs" :key="eventlog.id">
|
||||
<tr @click="eventlog.isOpen = !eventlog.isOpen" :class="{ 'active': eventlog.isOpen }" >
|
||||
<td style="white-space: nowrap;">{{ prettyLongDate(eventlog.raw.creationTime) }}</td>
|
||||
<td>{{ eventlog.source }}</td>
|
||||
<td v-html="eventlog.details"></td>
|
||||
</tr>
|
||||
<tr v-show="eventlog.isOpen">
|
||||
<td colspan="3" class="eventlog-details">
|
||||
<div v-if="eventlog.raw.source.ip" class="eventlog-source">Source IP: <span @click="onCopySource(eventlog)">{{ eventlog.raw.source.ip }}</span></div>
|
||||
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<EventlogList :fetch-page="fetchPage" :app="app" :available-actions="availableActions" :show-toolbar="false" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.eventlog-table {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
border-spacing: 0px;
|
||||
}
|
||||
|
||||
.eventlog-table th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.active,
|
||||
.eventlog-table tbody tr:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-table th,
|
||||
.eventlog-table td {
|
||||
padding: 10px 6px;
|
||||
}
|
||||
|
||||
.eventlog-filter {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.eventlog-details {
|
||||
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
|
||||
cursor: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.eventlog-source {
|
||||
padding-left: 10px;
|
||||
padding-bottom: 10px;
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.eventlog-details pre {
|
||||
white-space: pre-wrap;
|
||||
color: var(--pankow-text-color);
|
||||
font-size: 13px;
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.eventlog-list-item.active {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-list-item {
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { onMounted, ref, useTemplateRef, inject } from 'vue';
|
||||
import { Button, ClipboardAction } from '@cloudron/pankow';
|
||||
import { prettyDate } from '@cloudron/pankow/utils';
|
||||
import { stripSsoInfo } from '../../utils.js';
|
||||
import { marked } from 'marked';
|
||||
import { renderSafeMarkdown, stripSsoInfo } from '../../utils.js';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
@@ -32,7 +31,6 @@ async function onAckChecklistItem(item, key) {
|
||||
hasOldChecklist.value = true;
|
||||
}
|
||||
|
||||
// Notes
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
@@ -82,6 +80,7 @@ onMounted(() => {
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('app.updates.info.description') }}</div>
|
||||
<div class="info-value" v-if="app.appStoreId">{{ app.manifest.title }} {{ app.manifest.upstreamVersion }}</div>
|
||||
<div class="info-value" v-else-if="app.versionsUrl">{{ app.manifest.title }} {{ app.manifest.upstreamVersion }}</div>
|
||||
<div class="info-value" v-else>{{ app.manifest.dockerImage }}</div>
|
||||
</div>
|
||||
|
||||
@@ -102,6 +101,13 @@ onMounted(() => {
|
||||
<div class="info-value" v-else>{{ app.manifest.version }} <ClipboardAction plain :value="app.manifest.version"/></div>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="app.versionsUrl">
|
||||
<div class="info-label">{{ $t('app.updates.info.packager') }}</div>
|
||||
<div class="info-value">
|
||||
<a :href="app.manifest.packagerUrl" target="_blank">{{ app.manifest.packagerName }}</a> (community)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('app.updates.info.installedAt') }}</div>
|
||||
<div class="info-value">{{ prettyDate(app.creationTime) }}</div>
|
||||
@@ -121,14 +127,14 @@ onMounted(() => {
|
||||
|
||||
<div v-for="(item, key) in app.checklist" :key="key">
|
||||
<div class="checklist-item" v-if="!item.acknowledged">
|
||||
<span v-html="marked.parse(item.message)"></span>
|
||||
<span v-html="renderSafeMarkdown(item.message)"></span>
|
||||
<Button small plain tool style="margin-left: 10px;" @click="onAckChecklistItem(item, key)">{{ $t('main.dialog.done') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(item, key) in app.checklist" :key="key" v-show="showDoneChecklist">
|
||||
<div class="checklist-item checklist-item-acknowledged" v-if="item.acknowledged">
|
||||
<span v-html="marked.parse(item.message)"></span>
|
||||
<span v-html="renderSafeMarkdown(item.message)"></span>
|
||||
<span class="text-muted text-small">{{ item.changedBy }} - {{ prettyDate(item.changedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +149,7 @@ onMounted(() => {
|
||||
|
||||
<div>
|
||||
<div v-show="!editing">
|
||||
<div v-if="noteContent" v-html="marked.parse(stripSsoInfo(noteContent, app.sso))"></div>
|
||||
<div v-if="noteContent" v-html="renderSafeMarkdown(stripSsoInfo(noteContent, app.sso))"></div>
|
||||
<div v-else class="text-muted hand" @click="onEdit()">{{ placeholder }}</div>
|
||||
</div>
|
||||
<div v-show="editing">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, computed, inject } from 'vue';
|
||||
import { ref, useTemplateRef, onMounted, computed } from 'vue';
|
||||
import { Button, SingleSelect, InputGroup, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
|
||||
import { isValidDomain } from '@cloudron/pankow/utils';
|
||||
import { ISTATES } from '../../constants.js';
|
||||
@@ -13,7 +13,6 @@ const props = defineProps([ 'app' ]);
|
||||
const appsModel = AppsModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
const domains = ref([]);
|
||||
const busy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
@@ -55,36 +54,36 @@ function onAddRedirect() {
|
||||
});
|
||||
}
|
||||
|
||||
const formValid = computed(() => {
|
||||
if (!domain.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
const checkForDomains = [{
|
||||
domain: domain.value,
|
||||
subdomain: subdomain.value,
|
||||
}];
|
||||
|
||||
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].subdomain });
|
||||
for (const d of redirects.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
|
||||
for (const d of aliases.value) {
|
||||
let subdomain = d.subdomain;
|
||||
// see apps.js:validateLocations()
|
||||
if (d.subdomain.startsWith('*')) {
|
||||
if (subdomain === '*') continue;
|
||||
subdomain = subdomain.replace(/^\*\./, ''); // remove *.
|
||||
if (isFormValid.value) {
|
||||
const checkForDomains = [{ domain: domain.value, subdomain: subdomain.value }];
|
||||
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].subdomain });
|
||||
for (const d of redirects.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
|
||||
for (const d of aliases.value) {
|
||||
let subdomain = d.subdomain;
|
||||
// see apps.js:validateLocations()
|
||||
if (d.subdomain.startsWith('*')) {
|
||||
if (subdomain === '*') continue;
|
||||
subdomain = subdomain.replace(/^\*\./, ''); // remove *.
|
||||
}
|
||||
checkForDomains.push({ domain: d.domain, subdomain: subdomain });
|
||||
}
|
||||
checkForDomains.push({ domain: d.domain, subdomain: subdomain });
|
||||
|
||||
if (checkForDomains.find(d => !isValidDomain((d.subdomain ? (d.subdomain + '.') : '') + d.domain))) isFormValid.value = false;
|
||||
}
|
||||
|
||||
if (checkForDomains.find(d => !isValidDomain((d.subdomain ? (d.subdomain + '.') : '') + d.domain))) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function onRemoveRedirect(index) {
|
||||
redirects.value.splice(index, 1);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
errorMessage.value = '';
|
||||
errorObject.value = {};
|
||||
@@ -190,15 +189,17 @@ onMounted(async () => {
|
||||
}
|
||||
else console.error(`Portbinding ${p} not known in manifest!`);
|
||||
}
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" novalidate>
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="(app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid"/>
|
||||
<input type="submit" style="display: none;" :disabled="(app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId"/>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.location.location') }}</label>
|
||||
@@ -206,7 +207,7 @@ onMounted(async () => {
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<InputGroup style="flex-grow: 1">
|
||||
<TextInput style="flex-grow: 1" v-model="subdomain" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="domain" option-key="domain" option-label="label" :search-threshold="10"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="domain" option-key="domain" option-label="label" :search-threshold="10" required/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-if="isNoopOrManual(domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((subdomain ? subdomain + '.' : '') + domain) })"></div>
|
||||
<!-- Button just to offset the same margin on the right to align location input when alias or redirects are visible -->
|
||||
@@ -219,7 +220,7 @@ onMounted(async () => {
|
||||
<small>{{ item.description }}</small>
|
||||
<InputGroup style="flex-grow: 1">
|
||||
<TextInput style="flex-grow: 1" :id="'secondaryDomainInput' + item.containerPort" v-model="item.subdomain" :placeholder="$t('appstore.installDialog.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" :search-threshold="10"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" :search-threshold="10" required/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-if="isNoopOrManual(item.domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((item.subdomain ? item.subdomain + '.' : '') + item.domain) })"></div>
|
||||
</FormGroup>
|
||||
@@ -271,11 +272,12 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<div class="error-label" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<br v-if="errorMessage"/>
|
||||
|
||||
<Checkbox v-if="needsOverwriteDns" v-model="overwriteDns" :label="$t('app.location.dnsoverwrite')"/>
|
||||
<br v-if="needsOverwriteDns"/>
|
||||
|
||||
<Button @click="onSubmit()" :loading="busy" :disabled="busy || (app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid">{{ $t('app.location.saveAction') }}</Button>
|
||||
<Button @click="onSubmit()" :loading="busy" :disabled="busy || (app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !isFormValid">{{ $t('app.location.saveAction') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ onMounted(async () => {
|
||||
|
||||
<FormGroup v-if="volumeId !== DEFAULT_VOLUME_ID">
|
||||
<label for="volumePrefixInput">Subdirectory</label>
|
||||
<TextInput id="volumePrefixInput" placeholder="Prefix within the Volume" v-model="volumePrefix" />
|
||||
<TextInput id="volumePrefixInput" v-model="volumePrefix" />
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -225,7 +225,7 @@ onMounted(async () => {
|
||||
</FormGroup>
|
||||
|
||||
<br/>
|
||||
<Button @click="onSubmitMounts()" :loading="mountsBusy" :disabled="mountsBusy || (app.error && app.error.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || !!app.taskId || (!app.error && !mountsChanged) || !mountsValid">{{ $t('app.storage.mounts.saveAction') }}</Button>
|
||||
<Button @click="onSubmitMounts()" :loading="mountsBusy" :disabled="mountsBusy || !!app.taskId || !mountsChanged || !mountsValid">{{ $t('app.storage.mounts.saveAction') }}</Button>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,7 @@ const t = i18n.t;
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, InputDialog } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { APP_TYPES } from '../../constants.js';
|
||||
import { APP_TYPES, RSTATES, ISTATES } from '../../constants.js';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
@@ -55,6 +55,47 @@ async function onArchive() {
|
||||
window.location.href = '/#/apps';
|
||||
}
|
||||
|
||||
const TARGET_RUN_STATE = {
|
||||
START: Symbol('start'),
|
||||
STOP: Symbol('stop'),
|
||||
};
|
||||
|
||||
function targetRunState() {
|
||||
// if we have an error, we want to retry the pending state, otherwise toggle the runstate
|
||||
if (props.app.error) {
|
||||
if (props.app.error.installationState === ISTATES.PENDING_START) return TARGET_RUN_STATE.START;
|
||||
else return TARGET_RUN_STATE.STOP;
|
||||
} else {
|
||||
if (props.app.runState === RSTATES.STOPPED) return TARGET_RUN_STATE.START;
|
||||
else return TARGET_RUN_STATE.STOP;
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRunStateBusy = ref(false);
|
||||
async function onStartApp() {
|
||||
toggleRunStateBusy.value = true;
|
||||
|
||||
const [error] = await appsModel.start(props.app.id);
|
||||
if (error) {
|
||||
toggleRunStateBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
setTimeout(() => toggleRunStateBusy.value = false, 3000);
|
||||
}
|
||||
|
||||
async function onStopApp() {
|
||||
toggleRunStateBusy.value = true;
|
||||
|
||||
const [error] = await appsModel.stop(props.app.id);
|
||||
if (error) {
|
||||
toggleRunStateBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
setTimeout(() => toggleRunStateBusy.value = false, 3000);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
let [error, result] = await appsModel.backups(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
@@ -75,6 +116,22 @@ onMounted(async () => {
|
||||
<div>
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<div v-if="app.type !== APP_TYPES.PROXIED && targetRunState() === TARGET_RUN_STATE.START">
|
||||
<label>{{ $t('app.start.title') }}</label>
|
||||
<div v-html="$t('app.start.description')"></div>
|
||||
<br/>
|
||||
<Button primary :loading="toggleRunStateBusy" :disabled="app.error || toggleRunStateBusy" @click="onStartApp()">{{ $t('app.start.action') }}</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="app.type !== APP_TYPES.PROXIED && targetRunState() === TARGET_RUN_STATE.STOP">
|
||||
<label>{{ $t('app.stop.title') }}</label>
|
||||
<div v-html="$t('app.stop.description')"></div>
|
||||
<br/>
|
||||
<Button primary :loading="toggleRunStateBusy" :disabled="app.error || toggleRunStateBusy" @click="onStopApp()">{{ $t('app.stop.action') }}</Button>
|
||||
</div>
|
||||
|
||||
<hr style="margin-top: 20px"/>
|
||||
|
||||
<div v-if="app.type !== APP_TYPES.PROXIED">
|
||||
<label>{{ $t('app.archive.title') }}</label>
|
||||
<div v-html="$t('app.archive.description')"></div>
|
||||
|
||||
@@ -7,13 +7,11 @@ import { ISTATES } from '../../constants.js';
|
||||
import SettingsItem from '../SettingsItem.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import ProfileModel from '../../models/ProfileModel.js';
|
||||
import TasksModel from '../../models/TasksModel.js';
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const props = defineProps([ 'app', 'refresh-app' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
|
||||
const features = inject('features');
|
||||
|
||||
@@ -41,7 +39,7 @@ async function onAutoUpdatesEnabledChange(value) {
|
||||
async function waitForTask(id) {
|
||||
if (!id) return;
|
||||
|
||||
const [error, result] = await tasksModel.get(id);
|
||||
const [error, result] = await appsModel.getAppTask(props.app.id, id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
// task done, refresh menu
|
||||
@@ -59,6 +57,8 @@ async function onCheck() {
|
||||
const [error] = await appsModel.checkUpdate(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
await props.refreshApp();
|
||||
|
||||
busyCheck.value = false;
|
||||
}
|
||||
|
||||
@@ -66,10 +66,17 @@ async function onUpdate() {
|
||||
busyUpdate.value = true;
|
||||
updateError.value = '';
|
||||
|
||||
const [error, result] = await appsModel.update(props.app.id, props.app.updateInfo.manifest, skipBackup.value);
|
||||
let appData = '';
|
||||
if (props.app.appStoreId) {
|
||||
appData = { manifest: props.app.updateInfo.manifest };
|
||||
} else if (props.app.versionsUrl) {
|
||||
appData = { versionsUrl: `${props.app.versionsUrl}@${props.app.updateInfo.manifest.version}` };
|
||||
}
|
||||
|
||||
const [error, result] = await appsModel.update(props.app.id, appData, skipBackup.value);
|
||||
if (error) {
|
||||
busyUpdate.value = false;
|
||||
if (error.status === 400) updateError.value = error.body ? error.body.message : 'Internal error';
|
||||
if (error.status !== 202) updateError.value = error.body ? error.body.message : 'Internal error';
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
@@ -80,6 +87,8 @@ async function onUpdate() {
|
||||
|
||||
function onAskUpdate() {
|
||||
busyUpdate.value = false;
|
||||
updateError.value = '';
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
|
||||
@@ -112,26 +121,27 @@ onMounted(async () => {
|
||||
<div>{{ $t('app.updateDialog.changelogHeader', { version: app.updateInfo.manifest.version }) }}</div>
|
||||
<div class="changelog" v-html="marked.parse(app.updateInfo.manifest.changelog)"></div>
|
||||
<Checkbox class="skip-backup" v-model="skipBackup" :label="$t('app.updateDialog.skipBackupCheckbox')" />
|
||||
<div class="error-label" style="margin-top: 12px" v-if="updateError">{{ updateError }}</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<SettingsItem>
|
||||
<div>
|
||||
<label>{{ $t('app.updates.auto.title') }}</label>
|
||||
<div v-if="!app.appStoreId">{{ $t('app.updates.info.customAppUpdateInfo') }}</div>
|
||||
<div v-else v-html="$t('app.updates.auto.description')"></div>
|
||||
</div>
|
||||
<Switch v-if="app.appStoreId" v-model="autoUpdatesEnabled" :disabled="autoUpdatesEnabledBusy" @change="onAutoUpdatesEnabledChange"/>
|
||||
<div v-if="app.appStoreId || app.versionsUrl" v-html="$t('app.updates.auto.description')"></div>
|
||||
<div v-else>{{ $t('app.updates.info.customAppUpdateInfo') }}</div>
|
||||
</div>
|
||||
<Switch v-if="app.appStoreId || app.versionsUrl" v-model="autoUpdatesEnabled" :disabled="autoUpdatesEnabledBusy" @change="onAutoUpdatesEnabledChange"/>
|
||||
</SettingsItem>
|
||||
|
||||
<hr style="margin-top: 20px"/>
|
||||
|
||||
<div v-if="app.appStoreId">
|
||||
<div v-if="app.appStoreId || app.versionsUrl">
|
||||
<label>{{ $t('app.updatesTabTitle') }}</label>
|
||||
<div v-html="$t('app.updates.updates.description', { appStoreLink: 'https://www.cloudron.io/store/index.html' })"></div>
|
||||
<div>{{ $t('app.updates.updates.description') }}</div>
|
||||
</div>
|
||||
<br/>
|
||||
<Button v-if="app.appStoreId" @click="onCheck()" :disabled="busyCheck" :loading="busyCheck">{{ $t('settings.updates.checkForUpdatesAction') }}</Button>
|
||||
<Button v-if="app.appStoreId || app.versionsUrl" @click="onCheck()" :disabled="busyCheck" :loading="busyCheck">{{ $t('settings.updates.checkForUpdatesAction') }}</Button>
|
||||
|
||||
<hr v-if="app.updateInfo" style="margin-top: 20px"/>
|
||||
|
||||
@@ -142,7 +152,6 @@ onMounted(async () => {
|
||||
<div class="changelog" v-html="marked.parse(app.updateInfo.manifest.changelog)"></div>
|
||||
|
||||
<div class="error-label" style="margin-top: 12px" v-if="!features.appUpdates">{{ $t('app.updateDialog.subscriptionExpired') }}</div>
|
||||
<div class="error-label" style="margin-top: 12px" v-if="updateError">{{ updateError }}</div>
|
||||
<div class="error-label" style="margin-top: 12px" v-if="app.updateInfo.unstable">{{ $t('app.updateDialog.unstableWarning') }}</div>
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
+114
-2
@@ -180,16 +180,19 @@ const REGIONS_HETZNER = [
|
||||
{ name: 'Nuremberg (NBG1)', value: 'https://nbg1.your-objectstorage.com' }
|
||||
];
|
||||
|
||||
// https://docs.digitalocean.com/products/platform/availability-matrix/
|
||||
// https://docs.digitalocean.com/products/spaces/details/availability/
|
||||
const REGIONS_DIGITALOCEAN = [
|
||||
{ name: 'AMS3', value: 'https://ams3.digitaloceanspaces.com' },
|
||||
{ name: 'ATL1', value: 'https://atl1.digitaloceanspaces.com' },
|
||||
{ name: 'BLR1', value: 'https://blr1.digitaloceanspaces.com' },
|
||||
{ name: 'FRA1', value: 'https://fra1.digitaloceanspaces.com' },
|
||||
{ name: 'LON1', value: 'https://lon1.digitaloceanspaces.com' },
|
||||
{ name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' },
|
||||
{ name: 'SFO2', value: 'https://sfo2.digitaloceanspaces.com' },
|
||||
{ name: 'SFO3', value: 'https://sfo3.digitaloceanspaces.com' },
|
||||
{ name: 'SGP1', value: 'https://sgp1.digitaloceanspaces.com' },
|
||||
{ name: 'SYD1', value: 'https://syd1.digitaloceanspaces.com' }
|
||||
{ name: 'SYD1', value: 'https://syd1.digitaloceanspaces.com' },
|
||||
{ name: 'TOR1', value: 'https://tor1.digitaloceanspaces.com' }
|
||||
];
|
||||
|
||||
// https://www.exoscale.com/datacenters/
|
||||
@@ -335,6 +338,113 @@ const RELAY_PROVIDERS = [
|
||||
{ provider: 'noop', name: 'Disable outgoing email' },
|
||||
];
|
||||
|
||||
// keep in sync with src/eventlog.js
|
||||
const EVENTS = Object.freeze({
|
||||
ACTIVATE: 'cloudron.activate',
|
||||
PROVISION: 'cloudron.provision',
|
||||
RESTORE: 'cloudron.restore',
|
||||
START: 'cloudron.start',
|
||||
UPDATE: 'cloudron.update',
|
||||
UPDATE_FINISH: 'cloudron.update.finish',
|
||||
INSTALL_FINISH: 'cloudron.install.finish',
|
||||
|
||||
APP_CLONE: 'app.clone',
|
||||
APP_CONFIGURE: 'app.configure',
|
||||
APP_REPAIR: 'app.repair',
|
||||
APP_INSTALL: 'app.install',
|
||||
APP_RESTORE: 'app.restore',
|
||||
APP_IMPORT: 'app.import',
|
||||
APP_UNINSTALL: 'app.uninstall',
|
||||
APP_UPDATE: 'app.update',
|
||||
APP_UPDATE_FINISH: 'app.update.finish',
|
||||
APP_BACKUP: 'app.backup',
|
||||
APP_BACKUP_FINISH: 'app.backup.finish',
|
||||
APP_LOGIN: 'app.login',
|
||||
APP_OOM: 'app.oom',
|
||||
APP_UP: 'app.up',
|
||||
APP_DOWN: 'app.down',
|
||||
APP_START: 'app.start',
|
||||
APP_STOP: 'app.stop',
|
||||
APP_RESTART: 'app.restart',
|
||||
|
||||
ARCHIVES_ADD: 'archives.add',
|
||||
ARCHIVES_DEL: 'archives.del',
|
||||
|
||||
BACKUP_FINISH: 'backup.finish',
|
||||
BACKUP_START: 'backup.start',
|
||||
BACKUP_CLEANUP_START: 'backup.cleanup.start',
|
||||
BACKUP_CLEANUP_FINISH: 'backup.cleanup.finish',
|
||||
BACKUP_INTEGRITY_START: 'backup.integrity.start',
|
||||
BACKUP_INTEGRITY_FINISH: 'backup.integrity.finish',
|
||||
|
||||
BACKUP_SITE_ADD: 'backupsite.add',
|
||||
BACKUP_SITE_REMOVE: 'backupsite.remove',
|
||||
BACKUP_SITE_UPDATE: 'backupsite.update',
|
||||
|
||||
BRANDING_NAME: 'branding.name',
|
||||
BRANDING_FOOTER: 'branding.footer',
|
||||
BRANDING_AVATAR: 'branding.avatar',
|
||||
|
||||
CERTIFICATE_NEW: 'certificate.new',
|
||||
CERTIFICATE_RENEWAL: 'certificate.renew',
|
||||
CERTIFICATE_CLEANUP: 'certificate.cleanup',
|
||||
|
||||
DASHBOARD_DOMAIN_UPDATE: 'dashboard.domain.update',
|
||||
|
||||
DIRECTORY_SERVER_CONFIGURE: 'directoryserver.configure',
|
||||
|
||||
DOMAIN_ADD: 'domain.add',
|
||||
DOMAIN_UPDATE: 'domain.update',
|
||||
DOMAIN_REMOVE: 'domain.remove',
|
||||
|
||||
EXTERNAL_LDAP_CONFIGURE: 'externalldap.configure',
|
||||
|
||||
GROUP_ADD: 'group.add',
|
||||
GROUP_REMOVE: 'group.remove',
|
||||
GROUP_UPDATE: 'group.update',
|
||||
GROUP_MEMBERSHIP: 'group.membership',
|
||||
|
||||
MAIL_LOCATION: 'mail.location',
|
||||
MAIL_ENABLED: 'mail.enabled',
|
||||
MAIL_DISABLED: 'mail.disabled',
|
||||
MAIL_MAILBOX_ADD: 'mail.box.add',
|
||||
MAIL_MAILBOX_REMOVE: 'mail.box.remove',
|
||||
MAIL_MAILBOX_UPDATE: 'mail.box.update',
|
||||
MAIL_LIST_ADD: 'mail.list.add',
|
||||
MAIL_LIST_REMOVE: 'mail.list.remove',
|
||||
MAIL_LIST_UPDATE: 'mail.list.update',
|
||||
|
||||
REGISTRY_ADD: 'registry.add',
|
||||
REGISTRY_UPDATE: 'registry.update',
|
||||
REGISTRY_DEL: 'registry.del',
|
||||
|
||||
SERVICE_CONFIGURE: 'service.configure',
|
||||
SERVICE_REBUILD: 'service.rebuild',
|
||||
SERVICE_RESTART: 'service.restart',
|
||||
|
||||
USER_ADD: 'user.add',
|
||||
USER_LOGIN: 'user.login',
|
||||
USER_LOGIN_GHOST: 'user.login.ghost',
|
||||
USER_LOGOUT: 'user.logout',
|
||||
USER_REMOVE: 'user.remove',
|
||||
USER_UPDATE: 'user.update',
|
||||
USER_TRANSFER: 'user.transfer',
|
||||
|
||||
USER_DIRECTORY_PROFILE_CONFIG_UPDATE: 'userdirectory.profileconfig.update',
|
||||
|
||||
VOLUME_ADD: 'volume.add',
|
||||
VOLUME_UPDATE: 'volume.update',
|
||||
VOLUME_REMOUNT: 'volume.remount',
|
||||
VOLUME_REMOVE: 'volume.remove',
|
||||
|
||||
DYNDNS_UPDATE: 'dyndns.update',
|
||||
|
||||
SUPPORT_TICKET: 'support.ticket',
|
||||
SUPPORT_SSH: 'support.ssh',
|
||||
|
||||
PROCESS_CRASH: 'system.crash',
|
||||
});
|
||||
|
||||
// named exports
|
||||
export {
|
||||
API_ORIGIN,
|
||||
@@ -364,6 +474,7 @@ export {
|
||||
REGIONS_HETZNER,
|
||||
REGIONS_WASABI,
|
||||
REGIONS_S3,
|
||||
EVENTS,
|
||||
RELAY_PROVIDERS,
|
||||
};
|
||||
|
||||
@@ -396,5 +507,6 @@ export default {
|
||||
REGIONS_HETZNER,
|
||||
REGIONS_WASABI,
|
||||
REGIONS_S3,
|
||||
EVENTS,
|
||||
RELAY_PROVIDERS,
|
||||
};
|
||||
|
||||
@@ -5,8 +5,9 @@ import { API_ORIGIN } from './constants.js';
|
||||
const translations = {};
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'en', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages: translations,
|
||||
warnHtmlInMessage: 'off',
|
||||
// will replace our double {{}} to vue-i18n single brackets
|
||||
@@ -45,12 +46,7 @@ async function main() {
|
||||
|
||||
if (locale && locale !== 'en') {
|
||||
await loadLanguage(locale);
|
||||
|
||||
if (i18n.mode === 'legacy') {
|
||||
i18n.global.locale = locale;
|
||||
} else {
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
|
||||
return i18n;
|
||||
@@ -68,7 +64,7 @@ async function setLanguage(lang, profile = false) {
|
||||
console.error(`Failed to load language file for ${lang}`, e);
|
||||
}
|
||||
|
||||
i18n.global.locale = lang;
|
||||
i18n.global.locale.value = lang;
|
||||
}
|
||||
|
||||
export default main;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import '@fontsource/inter';
|
||||
// import "@fontsource/inter/100.css"; // Specify weight
|
||||
// import "@fontsource/inter/200.css"; // Specify weight
|
||||
// import "@fontsource/inter/300.css"; // Specify weight
|
||||
import "@fontsource/inter/400.css"; // Specify weight
|
||||
import "@fontsource/inter/500.css"; // Specify weight
|
||||
// import "@fontsource/inter/600.css"; // Specify weight
|
||||
|
||||
import { tooltip, fallbackImage } from '@cloudron/pankow';
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ function create() {
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body.appPasswords];
|
||||
},
|
||||
async add(identifier, name) {
|
||||
async add(identifier, name, expiresAt) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/app_passwords`, { identifier, name }, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/app_passwords`, { identifier, name, expiresAt }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
@@ -172,9 +172,41 @@ function create() {
|
||||
return {
|
||||
name: 'AppsModel',
|
||||
getTask,
|
||||
async install(manifest, config) {
|
||||
async listTasks(appId) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${appId}/tasks`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body.tasks];
|
||||
},
|
||||
async getAppTask(appId, taskId) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${appId}/tasks/${taskId}`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async stopAppTask(appId, taskId) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${appId}/tasks/${taskId}/stop`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 204) return [error || result];
|
||||
return [null];
|
||||
},
|
||||
async install(appData, config) {
|
||||
const data = {
|
||||
appStoreId: manifest.id + '@' + manifest.version,
|
||||
subdomain: config.subdomain,
|
||||
domain: config.domain,
|
||||
secondaryDomains: config.secondaryDomains,
|
||||
@@ -188,6 +220,13 @@ function create() {
|
||||
backupId: config.backupId // when restoring from archive
|
||||
};
|
||||
|
||||
// Support both appstore apps (manifest) and community apps (versionsUrl)
|
||||
if (appData.versionsUrl) {
|
||||
data.versionsUrl = appData.versionsUrl;
|
||||
} else if (appData.manifest) {
|
||||
data.appStoreId = `${appData.manifest.id}@${appData.manifest.version}`;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps`, data, { access_token: accessToken });
|
||||
@@ -307,10 +346,10 @@ function create() {
|
||||
if (result.status !== 202) return [result];
|
||||
return [null];
|
||||
},
|
||||
async getEvents(id) {
|
||||
async getEvents(id, filter = {}, page = 1, per_page = 100) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}/eventlog`, { page: 1, per_page: 100, access_token: accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}/eventlog`, { ...filter, page, per_page, access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
@@ -329,12 +368,18 @@ function create() {
|
||||
if (result.status !== 200) return [result];
|
||||
return [null, result.body.update];
|
||||
},
|
||||
async update(id, manifest, skipBackup = false) {
|
||||
async update(id, appData, skipBackup = false) {
|
||||
const data = {
|
||||
appStoreId: `${manifest.id}@${manifest.version}`,
|
||||
skipBackup: !!skipBackup,
|
||||
};
|
||||
|
||||
// Support both appstore apps (manifest) and community apps (versionsUrl)
|
||||
if (appData.versionsUrl) {
|
||||
data.versionsUrl = appData.versionsUrl;
|
||||
} else if (appData.manifest) {
|
||||
data.appStoreId = `${appData.manifest.id}@${appData.manifest.version}`;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/update`, data, { access_token: accessToken });
|
||||
|
||||
@@ -32,15 +32,26 @@ function create() {
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null];
|
||||
},
|
||||
async checkIntegrity(id) {
|
||||
async startIntegrityCheck(id) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/backups/${id}/check_integrity`, {}, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/backups/${id}/start_integrity_check`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
if (error || result.status !== 201) return [error || result];
|
||||
return [null, result.body.taskId];
|
||||
},
|
||||
async stopIntegrityCheck(id) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/backups/${id}/stop_integrity_check`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 204) return [error || result];
|
||||
return [null];
|
||||
},
|
||||
async get(id) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
import { fetcher } from '@cloudron/pankow';
|
||||
import { API_ORIGIN } from '../constants.js';
|
||||
|
||||
function create() {
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
return {
|
||||
async getApp(url, version) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/community/app`, { access_token: accessToken, url, version });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
if (result.status !== 200) return [result];
|
||||
return [null, result.body];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
};
|
||||
@@ -6,10 +6,10 @@ function create() {
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
return {
|
||||
async search(actions, search, page, per_page) {
|
||||
async search(filter, page, per_page) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/eventlog`, { actions, search, page, per_page, access_token: accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/eventlog`, { ...filter, page, per_page, access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ function ab2str(buf) {
|
||||
return String.fromCharCode.apply(null, new Uint16Array(buf));
|
||||
}
|
||||
|
||||
export function create(type, id) {
|
||||
export function create(type, id, options = {}) {
|
||||
const accessToken = localStorage.token;
|
||||
const INITIAL_STREAM_LINES = 100;
|
||||
|
||||
@@ -46,6 +46,9 @@ export function create(type, id) {
|
||||
} else if (type === 'service') {
|
||||
streamApi = `/api/v1/services/${id}/logstream`;
|
||||
downloadApi = `/api/v1/services/${id}/logs`;
|
||||
} else if (type === 'task' && options.appId) {
|
||||
streamApi = `/api/v1/apps/${options.appId}/tasks/${id}/logstream`;
|
||||
downloadApi = `/api/v1/apps/${options.appId}/tasks/${id}/logs`;
|
||||
} else if (type === 'task') {
|
||||
streamApi = `/api/v1/tasks/${id}/logstream`;
|
||||
downloadApi = `/api/v1/tasks/${id}/logs`;
|
||||
|
||||
@@ -292,10 +292,10 @@ function create() {
|
||||
if (result.status !== 202) return [result];
|
||||
return [null];
|
||||
},
|
||||
async eventlog(types, search, page, perPage) {
|
||||
async eventlog(types, search, page, perPage, from, to) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/eventlog`, { page, types, per_page: perPage, search, access_token: accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/eventlog`, { page, types, per_page: perPage, search, from, to, access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
|
||||
@@ -6,16 +6,30 @@ function create() {
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
return {
|
||||
async list(domain, search = '') {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/mail/${domain}/mailboxes`, { page: 1, per_page: 1000, access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
async list(domain) {
|
||||
const perPage = 5000;
|
||||
|
||||
let page = 1;
|
||||
let mailboxes = [];
|
||||
|
||||
while (true) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/mail/${domain}/mailboxes`, { page, per_page: perPage, access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
|
||||
if (result.status !== 200) return [result];
|
||||
|
||||
mailboxes = mailboxes.concat(result.body.mailboxes);
|
||||
|
||||
if (result.body.mailboxes.length < perPage) break;
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
if (result.status !== 200) return [result];
|
||||
return [null, result.body.mailboxes];
|
||||
return [null, mailboxes];
|
||||
},
|
||||
async get(domain, name) {
|
||||
let result;
|
||||
|
||||
@@ -6,10 +6,18 @@ function create() {
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
return {
|
||||
async list(acknowledged = false) {
|
||||
async list(acknowledged = null, page = 1) {
|
||||
const query = {
|
||||
access_token: accessToken,
|
||||
page,
|
||||
per_page: 100
|
||||
};
|
||||
|
||||
if (acknowledged !== null) query.acknowledged = !!acknowledged;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, { acknowledged, access_token: accessToken, per_page: 1000 });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, query);
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
|
||||
@@ -179,10 +179,10 @@ function create() {
|
||||
|
||||
return null;
|
||||
},
|
||||
async setTwoFASecret() {
|
||||
async setTotpSecret() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/twofactorauthentication_secret`, {}, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/totp_secret`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
@@ -190,10 +190,10 @@ function create() {
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async enableTwoFA(totpToken) {
|
||||
async enableTotp(totpToken) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/twofactorauthentication_enable`, { totpToken }, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/totp_enable`, { totpToken }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
@@ -204,7 +204,7 @@ function create() {
|
||||
async disableTwoFA(password) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/twofactorauthentication_disable`, { password }, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/totp_disable`, { password }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
@@ -234,6 +234,39 @@ function create() {
|
||||
if (error || result.status !== 201) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async getPasskeyRegistrationOptions() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/register/options`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async registerPasskey(credential, name) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/register`, { credential, name }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 201) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async deletePasskey(password) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/disable`, { password }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 204) return [error || result];
|
||||
return [null];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ function create() {
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body.update];
|
||||
},
|
||||
async getAutoupdatePattern() {
|
||||
async getAutoupdateConfig() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/updater/autoupdate_pattern`, { access_token: accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/updater/autoupdate_config`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
@@ -28,10 +28,10 @@ function create() {
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async setAutoupdatePattern(pattern) {
|
||||
async setAutoupdateConfig(schedule, policy) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/updater/autoupdate_pattern`, { pattern }, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/updater/autoupdate_config`, { schedule, policy }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
@@ -199,10 +199,10 @@ function create() {
|
||||
if (result.status !== 200) return [result];
|
||||
return [null, result.body.inviteLink];
|
||||
},
|
||||
async disableTwoFactorAuthentication(id) {
|
||||
async disableTotp(id) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/users/${id}/twofactorauthentication_disable`, {}, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/users/${id}/totp_disable`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
|
||||
+11
-78
@@ -18,8 +18,8 @@
|
||||
|
||||
html, body {
|
||||
font-size: 14px; /* this also defines the overall widget size as all sizes are in rem */
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
font-family: var(--pankow-font-family);
|
||||
font-weight: var(--pankow-font-weight-normal);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
@@ -36,10 +36,6 @@ html, body {
|
||||
}
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,.1);
|
||||
}
|
||||
@@ -48,9 +44,13 @@ b {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
font-family: var(--font-family--header);
|
||||
font-weight: 400;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -190,7 +190,7 @@ form .pankow-checkbox {
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: bold;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-small {
|
||||
@@ -205,14 +205,14 @@ form .pankow-checkbox {
|
||||
.warning-label {
|
||||
margin-top: 6px;
|
||||
color: #8a6d3b;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error-label {
|
||||
margin-top: 6px;
|
||||
color: var(--pankow-color-danger);
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ form .pankow-checkbox {
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
@@ -351,70 +351,3 @@ form .pankow-checkbox {
|
||||
border-top: solid 1px var(--pankow-input-border-color);
|
||||
border-bottom: solid 1px var(--pankow-input-border-color);
|
||||
}
|
||||
|
||||
/* eventlog classes shared in system and email eventlog views */
|
||||
.eventlog-table {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
border-spacing: 0px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.elide-table-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.eventlog-table thead {
|
||||
background-color: var(--pankow-body-background-color);
|
||||
top: 0;
|
||||
position: sticky;
|
||||
z-index: 1; /* avoids see-through table headers if items in the table have opacity set */
|
||||
}
|
||||
|
||||
.eventlog-table th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.active,
|
||||
.eventlog-table tbody tr:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-table th,
|
||||
.eventlog-table td {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.eventlog-filter {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.eventlog-details {
|
||||
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
|
||||
cursor: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.eventlog-source {
|
||||
padding-left: 10px;
|
||||
padding-bottom: 10px;
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.eventlog-details pre {
|
||||
white-space: pre-wrap;
|
||||
color: var(--pankow-text-color);
|
||||
font-size: 13px;
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
+160
-227
@@ -1,6 +1,26 @@
|
||||
|
||||
import { prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import { RELAY_PROVIDERS, ISTATES, STORAGE_PROVIDERS } from './constants.js';
|
||||
import { RELAY_PROVIDERS, ISTATES, EVENTS } from './constants.js';
|
||||
import { Marked } from 'marked';
|
||||
|
||||
function safeMarked() {
|
||||
const marked = new Marked({
|
||||
renderer: {
|
||||
link({ href, title, text }) {
|
||||
if (href && href.startsWith('mailto:')) return text; // mailto is rendered as text
|
||||
const titleAttr = title ? ` title="${title}"` : '';
|
||||
const isAbsolute = href && (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//'));
|
||||
const targetAttr = isAbsolute ? ' target="_blank"' : '';
|
||||
return `<a href="${href}"${targetAttr}${titleAttr}>${text}</a>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
return marked;
|
||||
}
|
||||
|
||||
function renderSafeMarkdown(text) {
|
||||
return safeMarked().parse(text);
|
||||
}
|
||||
|
||||
function prettyRelayProviderName(provider) {
|
||||
if (provider === 'noop') return 'Disabled (no email will be sent)';
|
||||
@@ -35,139 +55,7 @@ function s3like(provider) {
|
||||
|| provider === 'contabo-objectstorage' || provider === 'synology-c2-objectstorage';
|
||||
}
|
||||
|
||||
function regionName(provider, endpoint) {
|
||||
const storageProvider = STORAGE_PROVIDERS.find(sp => sp.value === provider);
|
||||
const regions = storageProvider.regions;
|
||||
if (!regions) return endpoint;
|
||||
const region = regions.find(r => r.value === endpoint);
|
||||
if (!region) return endpoint;
|
||||
return region.name;
|
||||
}
|
||||
|
||||
function prettySiteLocation(site) {
|
||||
switch (site.provider) {
|
||||
case 'filesystem':
|
||||
return site.config.backupDir + (site.config.prefix ? `/${site.config.prefix}` : '');
|
||||
case 'disk':
|
||||
case 'ext4':
|
||||
case 'xfs':
|
||||
case 'mountpoint':
|
||||
return (site.config.mountOptions.diskPath || site.config.mountPoint) + (site.config.prefix ? ` / ${site.config.prefix}` : '');
|
||||
case 'cifs':
|
||||
case 'nfs':
|
||||
case 'sshfs':
|
||||
return site.config.mountOptions.host + ':' + site.config.mountOptions.remoteDir + (site.config.prefix ? ` / ${site.config.prefix}` : '');
|
||||
case 's3':
|
||||
return site.config.region + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
|
||||
case 'minio':
|
||||
return site.config.endpoint + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
|
||||
case 'gcs':
|
||||
return site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
|
||||
default:
|
||||
return regionName(site.provider, site.config.endpoint) + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
|
||||
}
|
||||
}
|
||||
|
||||
function eventlogDetails(eventLog, app = null, appIdContext = '') {
|
||||
const ACTION_ACTIVATE = 'cloudron.activate';
|
||||
const ACTION_PROVISION = 'cloudron.provision';
|
||||
const ACTION_RESTORE = 'cloudron.restore';
|
||||
|
||||
const ACTION_APP_CLONE = 'app.clone';
|
||||
const ACTION_APP_REPAIR = 'app.repair';
|
||||
const ACTION_APP_CONFIGURE = 'app.configure';
|
||||
const ACTION_APP_INSTALL = 'app.install';
|
||||
const ACTION_APP_RESTORE = 'app.restore';
|
||||
const ACTION_APP_IMPORT = 'app.import';
|
||||
const ACTION_APP_UNINSTALL = 'app.uninstall';
|
||||
const ACTION_APP_UPDATE = 'app.update';
|
||||
const ACTION_APP_UPDATE_FINISH = 'app.update.finish';
|
||||
const ACTION_APP_BACKUP = 'app.backup';
|
||||
const ACTION_APP_BACKUP_FINISH = 'app.backup.finish';
|
||||
const ACTION_APP_LOGIN = 'app.login';
|
||||
const ACTION_APP_OOM = 'app.oom';
|
||||
const ACTION_APP_UP = 'app.up';
|
||||
const ACTION_APP_DOWN = 'app.down';
|
||||
const ACTION_APP_START = 'app.start';
|
||||
const ACTION_APP_STOP = 'app.stop';
|
||||
const ACTION_APP_RESTART = 'app.restart';
|
||||
|
||||
const ACTION_ARCHIVES_ADD = 'archives.add';
|
||||
const ACTION_ARCHIVES_DEL = 'archives.del';
|
||||
|
||||
const ACTION_BACKUP_FINISH = 'backup.finish';
|
||||
const ACTION_BACKUP_START = 'backup.start';
|
||||
const ACTION_BACKUP_CLEANUP_START = 'backup.cleanup.start';
|
||||
const ACTION_BACKUP_CLEANUP_FINISH = 'backup.cleanup.finish';
|
||||
|
||||
const ACTION_BACKUP_SITE_ADD = 'backupsite.add';
|
||||
const ACTION_BACKUP_SITE_REMOVE = 'backupsite.remove';
|
||||
const ACTION_BACKUP_SITE_UPDATE = 'backupsite.update';
|
||||
|
||||
const ACTION_BRANDING_AVATAR = 'branding.avatar';
|
||||
const ACTION_BRANDING_NAME = 'branding.name';
|
||||
const ACTION_BRANDING_FOOTER = 'branding.footer';
|
||||
|
||||
const ACTION_CERTIFICATE_NEW = 'certificate.new';
|
||||
const ACTION_CERTIFICATE_RENEWAL = 'certificate.renew';
|
||||
const ACTION_CERTIFICATE_CLEANUP = 'certificate.cleanup';
|
||||
|
||||
const ACTION_DASHBOARD_DOMAIN_UPDATE = 'dashboard.domain.update';
|
||||
|
||||
const ACTION_DIRECTORY_SERVER_CONFIGURE = 'directoryserver.configure';
|
||||
|
||||
const ACTION_DOMAIN_ADD = 'domain.add';
|
||||
const ACTION_DOMAIN_UPDATE = 'domain.update';
|
||||
const ACTION_DOMAIN_REMOVE = 'domain.remove';
|
||||
|
||||
const ACTION_EXTERNAL_LDAP_CONFIGURE = 'externalldap.configure';
|
||||
|
||||
const ACTION_GROUP_ADD = 'group.add';
|
||||
const ACTION_GROUP_UPDATE = 'group.update';
|
||||
const ACTION_GROUP_REMOVE = 'group.remove';
|
||||
const ACTION_GROUP_MEMBERSHIP = 'group.membership';
|
||||
|
||||
const ACTION_INSTALL_FINISH = 'cloudron.install.finish';
|
||||
|
||||
const ACTION_START = 'cloudron.start';
|
||||
const ACTION_SERVICE_CONFIGURE = 'service.configure';
|
||||
const ACTION_SERVICE_REBUILD = 'service.rebuild';
|
||||
const ACTION_SERVICE_RESTART = 'service.restart';
|
||||
const ACTION_UPDATE = 'cloudron.update';
|
||||
const ACTION_UPDATE_FINISH = 'cloudron.update.finish';
|
||||
const ACTION_USER_ADD = 'user.add';
|
||||
const ACTION_USER_LOGIN = 'user.login';
|
||||
const ACTION_USER_LOGIN_GHOST = 'user.login.ghost';
|
||||
const ACTION_USER_LOGOUT = 'user.logout';
|
||||
const ACTION_USER_REMOVE = 'user.remove';
|
||||
const ACTION_USER_UPDATE = 'user.update';
|
||||
const ACTION_USER_TRANSFER = 'user.transfer';
|
||||
|
||||
const ACTION_USER_DIRECTORY_PROFILE_CONFIG_UPDATE = 'userdirectory.profileconfig.update';
|
||||
|
||||
const ACTION_MAIL_LOCATION = 'mail.location';
|
||||
const ACTION_MAIL_ENABLED = 'mail.enabled';
|
||||
const ACTION_MAIL_DISABLED = 'mail.disabled';
|
||||
const ACTION_MAIL_MAILBOX_ADD = 'mail.box.add';
|
||||
const ACTION_MAIL_MAILBOX_UPDATE = 'mail.box.update';
|
||||
const ACTION_MAIL_MAILBOX_REMOVE = 'mail.box.remove';
|
||||
const ACTION_MAIL_LIST_ADD = 'mail.list.add';
|
||||
const ACTION_MAIL_LIST_UPDATE = 'mail.list.update';
|
||||
const ACTION_MAIL_LIST_REMOVE = 'mail.list.remove';
|
||||
|
||||
const ACTION_REGISTRY_ADD = 'registry.add';
|
||||
const ACTION_REGISTRY_UPDATE ='registry.update';
|
||||
const ACTION_REGISTRY_DEL = 'registry.del';
|
||||
|
||||
const ACTION_SUPPORT_TICKET = 'support.ticket';
|
||||
const ACTION_SUPPORT_SSH = 'support.ssh';
|
||||
|
||||
const ACTION_VOLUME_ADD = 'volume.add';
|
||||
const ACTION_VOLUME_UPDATE = 'volume.update';
|
||||
const ACTION_VOLUME_REMOVE = 'volume.remove';
|
||||
|
||||
const ACTION_DYNDNS_UPDATE = 'dyndns.update';
|
||||
|
||||
const data = eventLog.data;
|
||||
const errorMessage = data.errorMessage;
|
||||
let details;
|
||||
@@ -181,16 +69,16 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') {
|
||||
}
|
||||
|
||||
switch (eventLog.action) {
|
||||
case ACTION_ACTIVATE:
|
||||
case EVENTS.ACTIVATE:
|
||||
return 'Cloudron was activated';
|
||||
|
||||
case ACTION_PROVISION:
|
||||
case EVENTS.PROVISION:
|
||||
return 'Cloudron was setup';
|
||||
|
||||
case ACTION_RESTORE:
|
||||
case EVENTS.RESTORE:
|
||||
return 'Cloudron was restored using backup at ' + data.remotePath;
|
||||
|
||||
case ACTION_APP_CONFIGURE: {
|
||||
case EVENTS.APP_CONFIGURE: {
|
||||
if (!data.app) return '';
|
||||
app = data.app;
|
||||
|
||||
@@ -252,11 +140,15 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') {
|
||||
return appName('', app, 'App ') + 'was re-configured';
|
||||
}
|
||||
|
||||
case ACTION_APP_INSTALL:
|
||||
case EVENTS.APP_INSTALL:
|
||||
if (!data.app) return '';
|
||||
return data.app.manifest.title + ' (package v' + data.app.manifest.version + ') was installed ' + appName('at', data.app);
|
||||
details = data.app.versionsUrl ? 'Community app ' : '';
|
||||
details += data.app.manifest.title + ' (package v' + data.app.manifest.version + ')';
|
||||
details += data.sourceBuild ? ' was built and installed' : ' was installed';
|
||||
details += appIdContext ? '' : ` at ${data.app.fqdn}`;
|
||||
return details;
|
||||
|
||||
case ACTION_APP_RESTORE:
|
||||
case EVENTS.APP_RESTORE:
|
||||
if (!data.app) return '';
|
||||
details = appName('', data.app, 'App') + ' was restored';
|
||||
// older versions (<3.5) did not have these fields
|
||||
@@ -265,95 +157,94 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') {
|
||||
if (data.remotePath) details += ' using backup at ' + data.remotePath;
|
||||
return details;
|
||||
|
||||
case ACTION_APP_IMPORT:
|
||||
case EVENTS.APP_IMPORT:
|
||||
if (!data.app) return '';
|
||||
details = appName('', data.app, 'App') + ' was imported';
|
||||
if (data.toManifest) details += ' to version ' + data.toManifest.version;
|
||||
if (data.remotePath) details += ' using backup at ' + data.remotePath;
|
||||
return details;
|
||||
|
||||
case ACTION_APP_UNINSTALL:
|
||||
case EVENTS.APP_UNINSTALL:
|
||||
if (!data.app) return '';
|
||||
return appName('', data.app, 'App') + ' (package v' + data.app.manifest.version + ') was uninstalled';
|
||||
|
||||
case ACTION_APP_UPDATE:
|
||||
case EVENTS.APP_UPDATE:
|
||||
if (!data.app) return '';
|
||||
return 'Update ' + appName('of', data.app) + ' started from v' + data.fromManifest.version + ' to v' + data.toManifest.version;
|
||||
|
||||
case ACTION_APP_UPDATE_FINISH:
|
||||
case EVENTS.APP_UPDATE_FINISH:
|
||||
if (!data.app) return '';
|
||||
return appName('', data.app, 'App') + ' was updated to v' + data.app.manifest.version;
|
||||
|
||||
case ACTION_APP_BACKUP:
|
||||
case EVENTS.APP_BACKUP:
|
||||
if (!data.app) return '';
|
||||
return 'Backup ' + appName('of', data.app) + ' started';
|
||||
|
||||
case ACTION_APP_BACKUP_FINISH:
|
||||
case EVENTS.APP_BACKUP_FINISH:
|
||||
if (!data.app) return '';
|
||||
if (data.errorMessage) return 'Backup ' + appName('of', data.app) + ' failed: ' + data.errorMessage;
|
||||
if (errorMessage) return 'Backup ' + appName('of', data.app) + ' failed: ' + errorMessage;
|
||||
else return 'Backup ' + appName('of', data.app) + ' succeeded';
|
||||
|
||||
case ACTION_APP_CLONE:
|
||||
case EVENTS.APP_CLONE:
|
||||
if (appIdContext === data.oldAppId) return 'App was cloned to ' + data.newApp.fqdn + ' using backup at ' + data.remotePath;
|
||||
else if (appIdContext === data.appId) return 'App was cloned from ' + data.oldApp.fqdn + ' using backup at ' + data.remotePath;
|
||||
else return appName('', data.newApp, 'App') + ' was cloned ' + appName('from', data.oldApp) + ' using backup at ' + data.remotePath;
|
||||
|
||||
case ACTION_APP_REPAIR:
|
||||
case EVENTS.APP_REPAIR:
|
||||
return appName('', data.app, 'App') + ' was re-configured'; // re-configure of email apps is more common?
|
||||
|
||||
case ACTION_APP_LOGIN: {
|
||||
// const app = getApp(data.appId);
|
||||
case EVENTS.APP_LOGIN: {
|
||||
if (!app) return '';
|
||||
return 'App ' + app.fqdn + ' logged in';
|
||||
return appName('', app, 'App') + ' logged in';
|
||||
}
|
||||
|
||||
case ACTION_APP_OOM:
|
||||
case EVENTS.APP_OOM:
|
||||
if (!data.app) return '';
|
||||
return appName('', data.app, 'App') + ' ran out of memory';
|
||||
|
||||
case ACTION_APP_DOWN:
|
||||
case EVENTS.APP_DOWN:
|
||||
if (!data.app) return '';
|
||||
return appName('', data.app, 'App') + ' is down';
|
||||
|
||||
case ACTION_APP_UP:
|
||||
case EVENTS.APP_UP:
|
||||
if (!data.app) return '';
|
||||
return appName('', data.app, 'App') + ' is back online';
|
||||
|
||||
case ACTION_APP_START:
|
||||
case EVENTS.APP_START:
|
||||
if (!data.app) return '';
|
||||
return appName('', data.app, 'App') + ' was started';
|
||||
|
||||
case ACTION_APP_STOP:
|
||||
case EVENTS.APP_STOP:
|
||||
if (!data.app) return '';
|
||||
return appName('', data.app, 'App') + ' was stopped';
|
||||
|
||||
case ACTION_APP_RESTART:
|
||||
case EVENTS.APP_RESTART:
|
||||
if (!data.app) return '';
|
||||
return appName('', data.app, 'App') + ' was restarted';
|
||||
|
||||
case ACTION_ARCHIVES_ADD:
|
||||
case EVENTS.ARCHIVES_ADD:
|
||||
return 'Backup ' + data.backupId + ' added to archive';
|
||||
|
||||
case ACTION_ARCHIVES_DEL:
|
||||
case EVENTS.ARCHIVES_DEL:
|
||||
return 'Backup ' + data.backupId + ' deleted from archive';
|
||||
|
||||
case ACTION_BACKUP_START:
|
||||
case EVENTS.BACKUP_START:
|
||||
return `Backup started at site ${data.siteName}`;
|
||||
|
||||
case ACTION_BACKUP_FINISH:
|
||||
case EVENTS.BACKUP_FINISH:
|
||||
if (!errorMessage) return `Cloudron backup created at site ${data.siteName}`;
|
||||
else return `Cloudron backup at site ${data.siteName} errored with error: ${errorMessage}`;
|
||||
|
||||
case ACTION_BACKUP_CLEANUP_START:
|
||||
case EVENTS.BACKUP_CLEANUP_START:
|
||||
return 'Backup cleaner started';
|
||||
|
||||
case ACTION_BACKUP_CLEANUP_FINISH:
|
||||
return data.errorMessage ? 'Backup cleaner errored: ' + data.errorMessage : 'Backup cleaner removed ' + (data.removedBoxBackupPaths ? data.removedBoxBackupPaths.length : '0') + ' backup(s)';
|
||||
case EVENTS.BACKUP_CLEANUP_FINISH:
|
||||
return errorMessage ? 'Backup cleaner errored: ' + errorMessage : 'Backup cleaner removed ' + (data.removedBoxBackupPaths ? data.removedBoxBackupPaths.length : '0') + ' backup(s)';
|
||||
|
||||
case ACTION_BACKUP_SITE_ADD:
|
||||
case EVENTS.BACKUP_SITE_ADD:
|
||||
return `New backup site ${data.name} added with provider ${data.provider} and format ${data.format}`;
|
||||
|
||||
case ACTION_BACKUP_SITE_UPDATE:
|
||||
case EVENTS.BACKUP_SITE_UPDATE:
|
||||
if (data.schedule) {
|
||||
return `Backup site ${data.name} schedule was updated to ${data.schedule}`;
|
||||
} else if (data.limits) {
|
||||
@@ -372,170 +263,181 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') {
|
||||
return `Backup site ${data.name} was updated`;
|
||||
}
|
||||
|
||||
case ACTION_BACKUP_SITE_REMOVE:
|
||||
case EVENTS.BACKUP_SITE_REMOVE:
|
||||
return `Backup site ${data.name} removed`;
|
||||
|
||||
case ACTION_BRANDING_AVATAR:
|
||||
case EVENTS.BACKUP_INTEGRITY_START:
|
||||
return 'Backup integrity check started'; // for ${data.backupId}
|
||||
|
||||
case EVENTS.BACKUP_INTEGRITY_FINISH:
|
||||
if (!errorMessage) return `Backup integrity check ${data.status}`; // passed or failed
|
||||
else return `Backup integrity check errored: ${errorMessage}`;
|
||||
|
||||
case EVENTS.BRANDING_AVATAR:
|
||||
return 'Cloudron Avatar Changed';
|
||||
|
||||
case ACTION_BRANDING_NAME:
|
||||
case EVENTS.BRANDING_NAME:
|
||||
return 'Cloudron Name set to ' + data.name;
|
||||
|
||||
case ACTION_BRANDING_FOOTER:
|
||||
case EVENTS.BRANDING_FOOTER:
|
||||
return 'Cloudron Footer set to ' + data.footer;
|
||||
|
||||
case ACTION_CERTIFICATE_NEW:
|
||||
return 'Certificate installation for ' + data.domain + (errorMessage ? ' failed' : ' succeeded');
|
||||
case EVENTS.CERTIFICATE_NEW:
|
||||
details = 'Certificate installation for ' + data.domain + (errorMessage ? ' failed' : ' succeeded');
|
||||
if (data.renewalInfo) details += `. Recommended renewal time is between ${data.renewalInfo.start} and ${data.renewalInfo.end}`;
|
||||
return details;
|
||||
|
||||
case ACTION_CERTIFICATE_RENEWAL:
|
||||
case EVENTS.CERTIFICATE_RENEWAL:
|
||||
return 'Certificate renewal for ' + data.domain + (errorMessage ? ' failed' : ' succeeded');
|
||||
|
||||
case ACTION_CERTIFICATE_CLEANUP:
|
||||
case EVENTS.CERTIFICATE_CLEANUP:
|
||||
return 'Certificate(s) of ' + data.domains.join(',') + ' was cleaned up since they expired 6 months ago';
|
||||
|
||||
case ACTION_DASHBOARD_DOMAIN_UPDATE:
|
||||
case EVENTS.DASHBOARD_DOMAIN_UPDATE:
|
||||
return 'Dashboard domain set to ' + data.fqdn || (data.subdomain + '.' + data.domain);
|
||||
|
||||
case ACTION_DIRECTORY_SERVER_CONFIGURE:
|
||||
case EVENTS.DIRECTORY_SERVER_CONFIGURE:
|
||||
if (data.fromEnabled !== data.toEnabled) return 'Directory server was ' + (data.toEnabled ? 'enabled' : 'disabled');
|
||||
else return 'Directory server configuration was changed';
|
||||
|
||||
case ACTION_DOMAIN_ADD:
|
||||
case EVENTS.DOMAIN_ADD:
|
||||
return 'Domain ' + data.domain + ' with ' + data.provider + ' provider was added';
|
||||
|
||||
case ACTION_DOMAIN_UPDATE:
|
||||
case EVENTS.DOMAIN_UPDATE:
|
||||
return 'Domain ' + data.domain + ' with ' + data.provider + ' provider was updated';
|
||||
|
||||
case ACTION_DOMAIN_REMOVE:
|
||||
case EVENTS.DOMAIN_REMOVE:
|
||||
return 'Domain ' + data.domain + ' was removed';
|
||||
|
||||
case ACTION_EXTERNAL_LDAP_CONFIGURE:
|
||||
case EVENTS.EXTERNAL_LDAP_CONFIGURE:
|
||||
if (data.config.provider === 'noop') return 'External Directory disabled';
|
||||
else return 'External Directory set to ' + data.config.url + ' (' + data.config.provider + ')';
|
||||
|
||||
case ACTION_GROUP_ADD:
|
||||
case EVENTS.GROUP_ADD:
|
||||
return 'Group ' + data.name + ' was added';
|
||||
|
||||
case ACTION_GROUP_UPDATE:
|
||||
case EVENTS.GROUP_UPDATE:
|
||||
return 'Group name changed from ' + data.oldName + ' to ' + data.group.name;
|
||||
|
||||
case ACTION_GROUP_REMOVE:
|
||||
case EVENTS.GROUP_REMOVE:
|
||||
return 'Group ' + data.group.name + ' was removed';
|
||||
|
||||
case ACTION_GROUP_MEMBERSHIP:
|
||||
case EVENTS.GROUP_MEMBERSHIP:
|
||||
return 'Group membership of ' + data.group.name + ' changed. Now was ' + data.userIds.length + ' member(s).';
|
||||
|
||||
case ACTION_INSTALL_FINISH:
|
||||
case EVENTS.INSTALL_FINISH:
|
||||
return 'Cloudron version ' + data.version + ' installed';
|
||||
|
||||
case ACTION_MAIL_LOCATION:
|
||||
case EVENTS.MAIL_LOCATION:
|
||||
return 'Mail server location was changed to ' + data.subdomain + (data.subdomain ? '.' : '') + data.domain;
|
||||
|
||||
case ACTION_MAIL_ENABLED:
|
||||
case EVENTS.MAIL_ENABLED:
|
||||
return 'Mail was enabled for domain ' + data.domain;
|
||||
|
||||
case ACTION_MAIL_DISABLED:
|
||||
case EVENTS.MAIL_DISABLED:
|
||||
return 'Mail was disabled for domain ' + data.domain;
|
||||
|
||||
case ACTION_MAIL_MAILBOX_ADD:
|
||||
case EVENTS.MAIL_MAILBOX_ADD:
|
||||
return 'Mailbox ' + data.name + '@' + data.domain + ' was added';
|
||||
|
||||
case ACTION_MAIL_MAILBOX_UPDATE:
|
||||
case EVENTS.MAIL_MAILBOX_UPDATE:
|
||||
if (data.aliases) return 'Mailbox aliases of ' + data.name + '@' + data.domain + ' was updated';
|
||||
else return 'Mailbox ' + data.name + '@' + data.domain + ' was updated';
|
||||
|
||||
case ACTION_MAIL_MAILBOX_REMOVE:
|
||||
case EVENTS.MAIL_MAILBOX_REMOVE:
|
||||
return 'Mailbox ' + data.name + '@' + data.domain + ' was removed';
|
||||
|
||||
case ACTION_MAIL_LIST_ADD:
|
||||
case EVENTS.MAIL_LIST_ADD:
|
||||
return 'Mail list ' + data.name + '@' + data.domain + 'was added';
|
||||
|
||||
case ACTION_MAIL_LIST_UPDATE:
|
||||
case EVENTS.MAIL_LIST_UPDATE:
|
||||
return 'Mail list ' + data.name + '@' + data.domain + ' was updated';
|
||||
|
||||
case ACTION_MAIL_LIST_REMOVE:
|
||||
case EVENTS.MAIL_LIST_REMOVE:
|
||||
return 'Mail list ' + data.name + '@' + data.domain + ' was removed';
|
||||
|
||||
case ACTION_REGISTRY_ADD:
|
||||
case EVENTS.REGISTRY_ADD:
|
||||
return 'Docker registry ' + data.registry.provider + '@' + data.registry.serverAddress + ' was added';
|
||||
|
||||
case ACTION_REGISTRY_UPDATE:
|
||||
case EVENTS.REGISTRY_UPDATE:
|
||||
return 'Docker registry updated to ' + data.newRegistry.provider + '@' + data.newRegistry.serverAddress;
|
||||
|
||||
case ACTION_REGISTRY_DEL:
|
||||
case EVENTS.REGISTRY_DEL:
|
||||
return 'Docker registry ' + data.registry.provider + '@' + data.registry.serverAddress + ' was removed';
|
||||
|
||||
case ACTION_START:
|
||||
case EVENTS.START:
|
||||
return 'Cloudron started with version ' + data.version;
|
||||
|
||||
case ACTION_SERVICE_CONFIGURE:
|
||||
case EVENTS.SERVICE_CONFIGURE:
|
||||
return 'Service ' + data.id + ' was configured';
|
||||
|
||||
case ACTION_SERVICE_REBUILD:
|
||||
case EVENTS.SERVICE_REBUILD:
|
||||
return 'Service ' + data.id + ' was rebuilt';
|
||||
|
||||
case ACTION_SERVICE_RESTART:
|
||||
case EVENTS.SERVICE_RESTART:
|
||||
return 'Service ' + data.id + ' was restarted';
|
||||
|
||||
case ACTION_UPDATE:
|
||||
case EVENTS.UPDATE:
|
||||
return 'Cloudron update to version ' + data.boxUpdateInfo.version + ' was started';
|
||||
|
||||
case ACTION_UPDATE_FINISH:
|
||||
if (data.errorMessage) return 'Cloudron update errored. Error: ' + data.errorMessage;
|
||||
case EVENTS.UPDATE_FINISH:
|
||||
if (errorMessage) return 'Cloudron update errored. Error: ' + errorMessage;
|
||||
else return 'Cloudron updated to version ' + data.newVersion;
|
||||
|
||||
case ACTION_USER_ADD:
|
||||
case EVENTS.USER_ADD:
|
||||
return 'User ' + data.email + (data.user.username ? ' (' + data.user.username + ')' : '') + ' was added';
|
||||
|
||||
case ACTION_USER_UPDATE:
|
||||
case EVENTS.USER_UPDATE:
|
||||
return 'User ' + (data.user ? (data.user.email + (data.user.username ? ' (' + data.user.username + ')' : '')) : data.userId) + ' was updated';
|
||||
|
||||
case ACTION_USER_REMOVE:
|
||||
case EVENTS.USER_REMOVE:
|
||||
return 'User ' + (data.user ? (data.user.email + (data.user.username ? ' (' + data.user.username + ')' : '')) : data.userId) + ' was removed';
|
||||
|
||||
case ACTION_USER_TRANSFER:
|
||||
case EVENTS.USER_TRANSFER:
|
||||
return 'Apps of ' + data.oldOwnerId + ' was transferred to ' + data.newOwnerId;
|
||||
|
||||
case ACTION_USER_LOGIN:
|
||||
case EVENTS.USER_LOGIN:
|
||||
if (data.mailboxId) {
|
||||
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to mailbox ' + data.mailboxId;
|
||||
} else if (data.appId) {
|
||||
// const app = getApp(data.appId);
|
||||
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to ' + (app ? app.fqdn : data.appId);
|
||||
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in' + (appIdContext ? '' : ' to ' + (app ? app.fqdn : data.appId));
|
||||
} else { // can happen with directoryserver
|
||||
return 'User ' + (data.user ? data.user.username : data.userId) + ' authenticated';
|
||||
}
|
||||
|
||||
case ACTION_USER_LOGIN_GHOST:
|
||||
case EVENTS.USER_LOGIN_GHOST:
|
||||
return 'User ' + (data.user ? data.user.username : data.userId) + ' was impersonated';
|
||||
|
||||
case ACTION_USER_LOGOUT:
|
||||
case EVENTS.USER_LOGOUT:
|
||||
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged out';
|
||||
|
||||
case ACTION_USER_DIRECTORY_PROFILE_CONFIG_UPDATE:
|
||||
case EVENTS.USER_DIRECTORY_PROFILE_CONFIG_UPDATE:
|
||||
return 'User directory profile config updated. Mandatory 2FA: ' + (data.config.mandatory2FA) + ' Lock profiles: ' + (data.config.lockUserProfiles);
|
||||
|
||||
case ACTION_DYNDNS_UPDATE: {
|
||||
details = data.errorMessage ? 'Error updating DNS. ' : 'Updated DNS. ';
|
||||
case EVENTS.DYNDNS_UPDATE: {
|
||||
details = errorMessage ? 'Error updating DNS. ' : 'Updated DNS. ';
|
||||
if (data.fromIpv4 !== data.toIpv4) details += 'From IPv4 ' + data.fromIpv4 + ' to ' + data.toIpv4 + '. ';
|
||||
if (data.fromIpv6 !== data.toIpv6) details += 'From IPv6 ' + data.fromIpv6 + ' to ' + data.toIpv6 + '.';
|
||||
if (data.errorMessage) details += ' ' + data.errorMessage;
|
||||
if (errorMessage) details += ' ' + errorMessage;
|
||||
return details;
|
||||
}
|
||||
|
||||
case ACTION_SUPPORT_SSH:
|
||||
case EVENTS.SUPPORT_SSH:
|
||||
return 'Remote Support was ' + (data.enable ? 'enabled' : 'disabled');
|
||||
|
||||
case ACTION_SUPPORT_TICKET:
|
||||
case EVENTS.SUPPORT_TICKET:
|
||||
return 'Support ticket was created';
|
||||
|
||||
case ACTION_VOLUME_ADD:
|
||||
case EVENTS.VOLUME_ADD:
|
||||
return 'Volume "' + (data.volume || data).name + '" was added';
|
||||
|
||||
case ACTION_VOLUME_UPDATE:
|
||||
case EVENTS.VOLUME_UPDATE:
|
||||
return 'Volme "' + (data.volume || data).name + '" was updated';
|
||||
|
||||
case ACTION_VOLUME_REMOVE:
|
||||
case EVENTS.VOLUME_REMOUNT:
|
||||
return 'Volume "' + (data.volume || data).name + '" was remounted';
|
||||
|
||||
case EVENTS.VOLUME_REMOVE:
|
||||
return 'Volume "' + (data.volume || data).name + '" was removed';
|
||||
|
||||
default:
|
||||
@@ -758,8 +660,38 @@ function parseFullBackupPath(fullPath) {
|
||||
return { prefix, remotePath };
|
||||
}
|
||||
|
||||
function base64urlEncode(buffer) {
|
||||
return btoa(String.fromCharCode(...buffer))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function generateCodeVerifier() {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64urlEncode(array);
|
||||
}
|
||||
|
||||
async function computeCodeChallenge(verifier) {
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
||||
return base64urlEncode(new Uint8Array(hash));
|
||||
}
|
||||
|
||||
async function startAuthFlow(clientId, apiOrigin) {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await computeCodeChallenge(codeVerifier);
|
||||
|
||||
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
|
||||
sessionStorage.setItem('pkce_client_id', clientId);
|
||||
sessionStorage.setItem('pkce_api_origin', apiOrigin || '');
|
||||
|
||||
const redirectUri = window.location.origin + '/authcallback.html';
|
||||
const base = apiOrigin || '';
|
||||
return `${base}/openid/auth?client_id=${clientId}&scope=openid email profile&response_type=code&redirect_uri=${redirectUri}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
|
||||
}
|
||||
|
||||
// named exports
|
||||
export {
|
||||
renderSafeMarkdown,
|
||||
prettyRelayProviderName,
|
||||
download,
|
||||
mountlike,
|
||||
@@ -776,12 +708,13 @@ export {
|
||||
getColor,
|
||||
prettySchedule,
|
||||
parseSchedule,
|
||||
prettySiteLocation,
|
||||
parseFullBackupPath
|
||||
parseFullBackupPath,
|
||||
startAuthFlow
|
||||
};
|
||||
|
||||
// default export
|
||||
export default {
|
||||
renderSafeMarkdown,
|
||||
prettyRelayProviderName,
|
||||
download,
|
||||
mountlike,
|
||||
@@ -798,6 +731,6 @@ export default {
|
||||
getColor,
|
||||
prettySchedule,
|
||||
parseSchedule,
|
||||
prettySiteLocation,
|
||||
parseFullBackupPath
|
||||
parseFullBackupPath,
|
||||
startAuthFlow
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ref, useTemplateRef, onMounted } from 'vue';
|
||||
import { Button, Checkbox, FormGroup, TextInput, PasswordInput, EmailInput } from '@cloudron/pankow';
|
||||
import ProvisionModel from '../models/ProvisionModel.js';
|
||||
import { redirectIfNeeded } from '../utils.js';
|
||||
import { redirectIfNeeded, startAuthFlow } from '../utils.js';
|
||||
|
||||
const provisionModel = ProvisionModel.create();
|
||||
|
||||
@@ -13,7 +13,6 @@ const displayName = ref('');
|
||||
const email = ref('');
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const setupToken = ref('');
|
||||
const acceptLicense = ref(false);
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
@@ -33,7 +32,6 @@ async function onOwnerSubmit() {
|
||||
password: password.value,
|
||||
email: email.value,
|
||||
displayName: displayName.value,
|
||||
setupToken: setupToken.value,
|
||||
};
|
||||
|
||||
const [error, result] = await provisionModel.createAdmin(data);
|
||||
@@ -61,7 +59,7 @@ async function onOwnerSubmit() {
|
||||
// set token to autologin on first oidc flow
|
||||
localStorage.cloudronFirstTimeToken = result;
|
||||
|
||||
window.location.href = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
|
||||
window.location.href = await startAuthFlow('cid-webadmin', '');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -125,20 +125,20 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="archives" :busy="busy" :placeholder="$t('archives.listing.placeholder')">
|
||||
<template #icon="archive">
|
||||
<template #icon="{ item:archive }">
|
||||
<img :src="archive.iconUrl || 'img/appicon_fallback.png'" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'" height="24" width="24"/>
|
||||
</template>
|
||||
|
||||
<!-- for pre-8.2 backups, appConfig can be null -->
|
||||
<template #location="archive">{{ archive.appConfig ? archive.appConfig.fqdn : '-' }}</template>
|
||||
<template #location="{ item:archive }">{{ archive.appConfig ? archive.appConfig.fqdn : '-' }}</template>
|
||||
|
||||
<template #info="archive">
|
||||
<template #info="{ item:archive }">
|
||||
<span v-tooltip="`${archive.manifest.id}@${archive.manifest.version}`">{{ archive.manifest.title }}</span>
|
||||
</template>
|
||||
|
||||
<template #creationTime="archive">{{ prettyLongDate(archive.creationTime) }}</template>
|
||||
<template #creationTime="{ item:archive }">{{ prettyLongDate(archive.creationTime) }}</template>
|
||||
|
||||
<template #actions="archive">
|
||||
<template #actions="{ item:archive }">
|
||||
<ActionBar :actions="createActionMenu(archive)"/>
|
||||
</template>
|
||||
</TableView>
|
||||
|
||||
@@ -5,7 +5,7 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
|
||||
import { Button, ButtonGroup, ProgressBar } from '@cloudron/pankow';
|
||||
import { Button, ButtonGroup, ProgressBar, InputDialog } from '@cloudron/pankow';
|
||||
import PostInstallDialog from '../components/PostInstallDialog.vue';
|
||||
import SftpInfoDialog from '../components/SftpInfoDialog.vue';
|
||||
import Access from '../components/app/Access.vue';
|
||||
@@ -26,13 +26,13 @@ import Storage from '../components/app/Storage.vue';
|
||||
import Uninstall from '../components/app/Uninstall.vue';
|
||||
import Updates from '../components/app/Updates.vue';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import TasksModel from '../models/TasksModel.js';
|
||||
import { API_ORIGIN, APP_TYPES, ISTATES, RSTATES, HSTATES } from '../constants.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
const installationStateLabel = AppsModel.installationStateLabel;
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
|
||||
const busy = ref(true);
|
||||
const id = ref('');
|
||||
const app = ref(null);
|
||||
@@ -103,6 +103,16 @@ async function refresh() {
|
||||
target: '_blank',
|
||||
|
||||
});
|
||||
|
||||
if (result.versionsUrl) {
|
||||
infoMenu.value.push({ separator: true });
|
||||
infoMenu.value.push({
|
||||
label: 'Versions URL',
|
||||
href: result.versionsUrl,
|
||||
target: '_blank',
|
||||
});
|
||||
}
|
||||
|
||||
infoMenu.value.push({ separator: true });
|
||||
infoMenu.value.push({
|
||||
label: t('app.projectWebsiteAction'),
|
||||
@@ -139,7 +149,7 @@ function isViewEnabled(view, errorState) {
|
||||
} else if (view === 'resources') {
|
||||
return errorState === ISTATES.PENDING_RESIZE || errorState === ISTATES.PENDING_RECREATE_CONTAINER;
|
||||
} else if (view === 'storage') {
|
||||
return errorState === ISTATES.PENDING_DATA_DIR_MIGRATION || errorState === ISTATES.PENDING_RECREATE_CONTAINER;
|
||||
return true; // allow in all states because a volume error can happen at any time
|
||||
} else if (view === 'services') {
|
||||
return errorState === ISTATES.PENDING_SERVICES_CHANGE;
|
||||
} else if (view === 'email') {
|
||||
@@ -151,53 +161,12 @@ function isViewEnabled(view, errorState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const TARGET_RUN_STATE = {
|
||||
START: Symbol('start'),
|
||||
STOP: Symbol('stop'),
|
||||
};
|
||||
|
||||
function targetRunState() {
|
||||
// if we have an error, we want to retry the pending state, otherwise toggle the runstate
|
||||
if (app.value.error) {
|
||||
if (app.value.error.installationState === ISTATES.PENDING_START) return TARGET_RUN_STATE.START;
|
||||
else return TARGET_RUN_STATE.STOP;
|
||||
} else {
|
||||
if (app.value.runState === RSTATES.STOPPED) return TARGET_RUN_STATE.START;
|
||||
else return TARGET_RUN_STATE.STOP;
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRunStateBusy = ref(false);
|
||||
async function onStartApp() {
|
||||
toggleRunStateBusy.value = true;
|
||||
|
||||
const [error] = await appsModel.start(app.value.id);
|
||||
if (error) {
|
||||
toggleRunStateBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
setTimeout(() => toggleRunStateBusy.value = false, 3000);
|
||||
}
|
||||
|
||||
async function onStopApp() {
|
||||
toggleRunStateBusy.value = true;
|
||||
|
||||
const [error] = await appsModel.stop(app.value.id);
|
||||
if (error) {
|
||||
toggleRunStateBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
setTimeout(() => toggleRunStateBusy.value = false, 3000);
|
||||
}
|
||||
|
||||
async function onStopAppTask() {
|
||||
if (!app.value.taskId) return;
|
||||
|
||||
busyStopTask.value = true;
|
||||
|
||||
const [error] = await tasksModel.stop(app.value.taskId);
|
||||
const [error] = await appsModel.stopAppTask(app.value.id, app.value.taskId);
|
||||
if (error) console.error(error);
|
||||
|
||||
busyStopTask.value = false;
|
||||
@@ -226,6 +195,48 @@ function hashChange() {
|
||||
window.location.hash = `/app/${id.value}/${newView}`;
|
||||
}
|
||||
|
||||
const busyRestart = ref(false);
|
||||
|
||||
async function onRestartApp() {
|
||||
if (app.value.runState === RSTATES.STOPPED) {
|
||||
busyRestart.value = true;
|
||||
|
||||
const [error] = await appsModel.restart(id.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
busyRestart.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await inputDialog.value.confirm({
|
||||
message: t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmLabel: t('main.action.restart'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
busyRestart.value = true;
|
||||
|
||||
const [error] = await appsModel.restart(id.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
busyRestart.value = false;
|
||||
}
|
||||
|
||||
const busyStart = ref(false);
|
||||
|
||||
async function onStartApp() {
|
||||
busyStart.value = true;
|
||||
|
||||
const [error] = await appsModel.start(id.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
setTimeout(() => busyStart.value = false, 3000);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const tmp = window.location.hash.slice('#/app/'.length);
|
||||
if (!tmp) return;
|
||||
@@ -284,6 +295,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="configure-outer">
|
||||
<InputDialog ref="inputDialog" />
|
||||
<PostInstallDialog ref="postInstallDialog"/>
|
||||
<SftpInfoDialog ref="sftpInfoDialog"/>
|
||||
|
||||
@@ -303,12 +315,8 @@ onBeforeUnmount(() => {
|
||||
<Button v-if="app.taskId" danger tool plain icon="fa-solid fa-xmark" v-tooltip="'Cancel Task'" :loading="busyStopTask" :disabled="busyStopTask" @click="onStopAppTask()"/>
|
||||
<Button :menu="views" secondary class="pankow-no-desktop" tool>{{ views.find(v => v.id === currentView).label }}</Button>
|
||||
|
||||
<!--
|
||||
TODO check if this should be shown on stop confirmation
|
||||
<div>{{ $t('app.uninstall.startStop.description') }}</div>
|
||||
-->
|
||||
<Button v-if="!app.progress && targetRunState() === TARGET_RUN_STATE.START" secondary tool icon="fa-solid fa-circle-play" :loading="toggleRunStateBusy" :disabled="toggleRunStateBusy" v-tooltip="$t('app.uninstall.startStop.startAction')" @click="onStartApp()"/>
|
||||
<Button v-else-if="!app.progress" secondary tool icon="fa-solid fa-circle-stop" :loading="toggleRunStateBusy" :disabled="toggleRunStateBusy" v-tooltip="$t('app.uninstall.startStop.stopAction')" @click="onStopApp()"/>
|
||||
<Button v-if="!app.progress && app.runState !== RSTATES.STOPPED" secondary tool icon="fa-solid fa-arrows-rotate" :loading="busyRestart" :disabled="busyRestart" v-tooltip="$t('filemanager.toolbar.restartApp')" @click="onRestartApp()"/>
|
||||
<Button v-if="!app.progress && app.runState === RSTATES.STOPPED" secondary tool icon="fa-solid fa-circle-play" :loading="busyStart" :disabled="busyStart" v-tooltip="$t('app.start.action')" @click="onStartApp()"/>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button secondary tool :href="`/logs.html?appId=${app.id}`" target="_blank" v-tooltip="$t('app.logsActionTooltip')" icon="fa-solid fa-align-left" />
|
||||
@@ -342,7 +350,7 @@ onBeforeUnmount(() => {
|
||||
<Security v-else-if="currentView === 'security'" :app="app"/>
|
||||
<Email v-else-if="currentView === 'email'" :app="app"/>
|
||||
<Cron v-else-if="currentView === 'cron'" :app="app"/>
|
||||
<Updates v-else-if="currentView === 'updates'" :app="app"/>
|
||||
<Updates v-else-if="currentView === 'updates'" :app="app" :refresh-app="refresh"/>
|
||||
<Backups v-else-if="currentView === 'backups'" :app="app"/>
|
||||
<Repair v-else-if="currentView === 'repair'" :app="app"/>
|
||||
<Eventlog v-else-if="currentView === 'eventlog'" :app="app"/>
|
||||
@@ -485,7 +493,7 @@ onBeforeUnmount(() => {
|
||||
.configure-menu-item[active] > a,
|
||||
.configure-menu-item[active] > span {
|
||||
color: var(--pankow-color-primary-active);
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.configure-menu-item:hover > a,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, computed, useTemplateRef, onActivated, onDeactivated, inject } from 'vue';
|
||||
import { ref, computed, useTemplateRef, watch, onActivated, onDeactivated, inject } from 'vue';
|
||||
import { Button, SingleSelect, Icon, TableView, TextInput, ProgressBar } from '@cloudron/pankow';
|
||||
import { API_ORIGIN, APP_TYPES, HSTATES, ISTATES, RSTATES } from '../constants.js';
|
||||
import ActionBar from '../components/ActionBar.vue';
|
||||
@@ -45,6 +45,27 @@ const stateFilterOptions = [
|
||||
{ id: 'update_available', label: 'Update available' },
|
||||
{ id: 'not_responding', label: 'Not responding' },
|
||||
];
|
||||
const STATE_FILTER_IDS = new Set(['', 'running', 'stopped', 'update_available', 'not_responding']);
|
||||
const APPS_HASH_PATH = '#/apps';
|
||||
|
||||
function syncFiltersToUrl() {
|
||||
if (window.location.hash.split('?')[0] !== APPS_HASH_PATH) return;
|
||||
const params = new URLSearchParams();
|
||||
if (filter.value) params.set('search', filter.value);
|
||||
if (domainFilter.value) params.set('domain', domainFilter.value);
|
||||
if (stateFilter.value) params.set('state', stateFilter.value);
|
||||
if (tagFilter.value) params.set('tag', tagFilter.value);
|
||||
const query = params.toString();
|
||||
const newHash = query ? `${APPS_HASH_PATH}?${query}` : APPS_HASH_PATH;
|
||||
history.replaceState(null, '', newHash);
|
||||
}
|
||||
|
||||
let filterDebounceTimer;
|
||||
function scheduleSyncFilterToUrl() {
|
||||
clearTimeout(filterDebounceTimer);
|
||||
filterDebounceTimer = setTimeout(syncFiltersToUrl, 350);
|
||||
}
|
||||
|
||||
const listColumns = {
|
||||
icon: {
|
||||
width: '40px'
|
||||
@@ -283,9 +304,39 @@ function onKeyDownHandler(event) {
|
||||
if (event.key === 'Escape') filter.value = '';
|
||||
}
|
||||
|
||||
function onAppsHashChange() {
|
||||
if (window.location.hash.split('?')[0] !== APPS_HASH_PATH) return;
|
||||
if (window.location.hash.indexOf('?') >= 0) return;
|
||||
filter.value = '';
|
||||
domainFilter.value = '';
|
||||
stateFilter.value = '';
|
||||
tagFilter.value = '';
|
||||
}
|
||||
|
||||
watch(filter, () => scheduleSyncFilterToUrl());
|
||||
watch(domainFilter, syncFiltersToUrl);
|
||||
watch(stateFilter, syncFiltersToUrl);
|
||||
watch(tagFilter, syncFiltersToUrl);
|
||||
|
||||
onActivated(async () => {
|
||||
setItemWidth();
|
||||
|
||||
const qi = window.location.hash.indexOf('?');
|
||||
const params = qi >= 0 ? new URLSearchParams(window.location.hash.slice(qi + 1)) : new URLSearchParams();
|
||||
|
||||
filter.value = params.get('search') ?? '';
|
||||
|
||||
const stateParam = params.get('state') ?? '';
|
||||
stateFilter.value = STATE_FILTER_IDS.has(stateParam) ? stateParam : '';
|
||||
|
||||
const domainParam = params.get('domain') ?? '';
|
||||
const domainExists = domainFilterOptions.value.some(opt => opt.id === domainParam);
|
||||
domainFilter.value = domainExists ? domainParam : '';
|
||||
|
||||
const tagParam = params.get('tag') ?? '';
|
||||
const tagExists = tagFilterOptions.value.some(opt => opt.id === tagParam);
|
||||
tagFilter.value = tagExists ? tagParam : '';
|
||||
|
||||
await refreshApps();
|
||||
ready.value = true;
|
||||
|
||||
@@ -293,23 +344,19 @@ onActivated(async () => {
|
||||
if (error) return console.error(error);
|
||||
|
||||
domainFilterOptions.value = [{ id: '', domain: 'All domains', }].concat(result.map(d => { d.id = d.domain; return d; }));
|
||||
domainFilter.value = domainFilterOptions.value[0].id;
|
||||
|
||||
stateFilter.value = stateFilterOptions[0].id;
|
||||
tagFilter.value = tagFilterOptions.value[0].id;
|
||||
|
||||
refreshInterval = setInterval(refreshApps, 5000);
|
||||
|
||||
window.addEventListener('resize', setItemWidth);
|
||||
window.addEventListener('keydown', onKeyDownHandler);
|
||||
window.addEventListener('hashchange', onAppsHashChange);
|
||||
|
||||
if (window.innerWidth > 575) setTimeout(() => searchInput.value.focus(), 0);
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
filter.value = '';
|
||||
|
||||
window.removeEventListener('keydown', onKeyDownHandler);
|
||||
window.removeEventListener('hashchange', onAppsHashChange);
|
||||
clearInterval(refreshInterval);
|
||||
});
|
||||
|
||||
@@ -359,35 +406,35 @@ onDeactivated(() => {
|
||||
<div class="list" v-if="viewType === VIEW_TYPE.LIST && apps.length !== 0">
|
||||
|
||||
<TableView :columns="listColumns" :model="filteredApps">
|
||||
<template #icon="app">
|
||||
<template #icon="{ item:app }">
|
||||
<a :href="app.origin" target="_blank">
|
||||
<img :alt="app.label || app.subdomain || app.fqdn" class="list-icon" :class="{ 'item-inactive': app.runState === RSTATES.STOPPED }" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
|
||||
</a>
|
||||
</template>
|
||||
<template #label="app">
|
||||
<template #label="{ item:app }">
|
||||
<a :href="app.origin" target="_blank">
|
||||
{{ app.label || app.subdomain || app.fqdn }}
|
||||
</a>
|
||||
</template>
|
||||
<template #appTitle="app">
|
||||
<template #appTitle="{ item:app }">
|
||||
{{ app.manifest.title }}
|
||||
</template>
|
||||
<template #fqdn="app">
|
||||
<template #fqdn="{ item:app }">
|
||||
<a :href="app.origin" target="_blank">
|
||||
{{ app.fqdn }}
|
||||
</a>
|
||||
</template>
|
||||
<template #status="app">
|
||||
<template #status="{ item:app }">
|
||||
<div class="list-status">
|
||||
{{ AppsModel.installationStateLabel(app) }}
|
||||
<ProgressBar v-if="app.progress && isOperator(app)" :busy="true" :value="Math.max(10, app.progress)" :show-label="false" class="apps-progress"/>
|
||||
</div>
|
||||
</template>
|
||||
<template #checklist="app">
|
||||
<template #checklist="{ item:app }">
|
||||
<a class="list-item-checklist-indicator" v-if="AppsModel.pendingChecklistItems(app)" :href="`#/app/${app.id}/info`"><Icon icon="fa-solid fa-triangle-exclamation"/></a>
|
||||
<a class="list-item-update-indicator" v-if="app.updateInfo" @click.stop :href="isOperator(app) ? `#/app/${app.id}/updates` : null" v-tooltip="$t('app.updateAvailableTooltip')"><i class="fa-solid fa-arrow-up"/></a>
|
||||
</template>
|
||||
<template #sso="app">
|
||||
<template #sso="{ item:app }">
|
||||
<div v-show="app.type !== APP_TYPES.LINK">
|
||||
<Icon icon="fa-brands fa-openid" v-show="app.ssoAuth && app.manifest.addons.oidc" v-tooltip="$t('apps.auth.openid')" />
|
||||
<Icon icon="fas fa-user" v-show="app.ssoAuth && (!app.manifest.addons.oidc && !app.manifest.addons.email)" v-tooltip="$t('apps.auth.sso')" />
|
||||
@@ -395,7 +442,7 @@ onDeactivated(() => {
|
||||
<Icon icon="fas fa-envelope" v-show="app.manifest.addons.email" v-tooltip="$t('apps.auth.email')" />
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="app">
|
||||
<template #actions="{ item:app }">
|
||||
<ActionBar v-if="app.type === APP_TYPES.LINK" :actions="createAppLinkActionMenu(app)" />
|
||||
<ActionBar v-else :actions="createAppActionMenu(app)" />
|
||||
</template>
|
||||
@@ -405,8 +452,8 @@ onDeactivated(() => {
|
||||
<div v-if="apps.length === 0" class="empty-placeholder">
|
||||
<!-- admins or not-->
|
||||
<div v-if="profile.isAtLeastAdmin">
|
||||
<h4>{{ $t('apps.noApps.title') }}</h4>
|
||||
<h5 v-html="$t('apps.noApps.description', { appStoreLink: '#/appstore' })"></h5>
|
||||
<h4 style="font-weight: 400">{{ $t('apps.noApps.title') }}</h4>
|
||||
<h5 v-html="$t('apps.noApps.description', { appStoreLink: '#/appstore' })" style="font-weight: 400"></h5>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h4>{{ $t('apps.noAccess.title') }}</h4>
|
||||
|
||||
@@ -13,6 +13,7 @@ import DomainsModel from '../models/DomainsModel.js';
|
||||
import ApplinkDialog from '../components/ApplinkDialog.vue';
|
||||
import AppInstallDialog from '../components/AppInstallDialog.vue';
|
||||
import AppStoreItem from '../components/AppStoreItem.vue';
|
||||
import CommunityAppDialog from '../components/CommunityAppDialog.vue';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const appstoreModel = AppstoreModel.create();
|
||||
@@ -22,6 +23,17 @@ const ready = ref(false);
|
||||
const apps = ref([]);
|
||||
const search = ref('');
|
||||
const domains = ref([]);
|
||||
const addCustomAppMenu = ref([{
|
||||
label: 'App proxy',
|
||||
action: () => { window.location.href="/#/appstore/io.cloudron.builtin.appproxy"; }
|
||||
}, {
|
||||
label: 'Community app',
|
||||
action: () => { onInstallCommunityApp(); }
|
||||
}, {
|
||||
label: 'External link',
|
||||
action: () => { onAddAppLink(); }
|
||||
},
|
||||
]);
|
||||
|
||||
// clear category on search
|
||||
watch(search, (newValue) => {
|
||||
@@ -47,8 +59,9 @@ const filteredApps = computed(() => {
|
||||
return filterForNewApps(apps.value);
|
||||
} else {
|
||||
return apps.value.filter(a => {
|
||||
if (a.manifest.tags.join().toLowerCase().indexOf(category.value) !== -1) return true;
|
||||
return false;
|
||||
const matchTags = categoryTagMap[category.value];
|
||||
if (!matchTags) return a.manifest.tags.some(tag => tag.toLowerCase() === category.value);
|
||||
return a.manifest.tags.some(tag => matchTags.includes(tag.toLowerCase()));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -81,32 +94,118 @@ const category = ref('');
|
||||
const categories = [
|
||||
{ id: '', label: t('appstore.category.all') },
|
||||
{ id: 'new', label: t('appstore.category.newApps') },
|
||||
{ id: 'ai', label: 'AI'},
|
||||
{ id: 'analytics', label: 'Analytics'},
|
||||
{ id: 'automation', label: 'Automation'},
|
||||
{ id: 'blog', label: 'Blog'},
|
||||
{ id: 'calendar', label: 'Calendar'},
|
||||
{ id: 'chat', label: 'Chat'},
|
||||
{ id: 'crm', label: 'CRM'},
|
||||
{ id: 'document', label: 'Documents'},
|
||||
{ id: 'email', label: 'Email'},
|
||||
{ id: 'federated', label: 'Federated'},
|
||||
{ id: 'finance', label: 'Finance'},
|
||||
{ id: 'forum', label: 'Forum'},
|
||||
{ id: 'fun', label: 'Fun'},
|
||||
{ id: 'gallery', label: 'Gallery'},
|
||||
{ id: 'game', label: 'Games'},
|
||||
{ id: 'git', label: 'Code Hosting'},
|
||||
{ id: 'hosting', label: 'Web Hosting'},
|
||||
{ id: 'learning', label: 'Learning'},
|
||||
{ id: 'media', label: 'Media'},
|
||||
{ id: 'no-code', label: 'No-code'},
|
||||
{ id: 'notes', label: 'Notes'},
|
||||
{ id: 'project', label: 'Project Management'},
|
||||
{ id: 'rss', label: 'RSS'},
|
||||
{ id: 'security', label: 'Security'},
|
||||
{ id: 'sync', label: 'File Sync'},
|
||||
{ id: 'voip', label: 'VoIP'},
|
||||
{ id: 'vpn', label: 'VPN'},
|
||||
{ id: 'wiki', label: 'Wiki'},
|
||||
];
|
||||
|
||||
const categoryTagMap = {
|
||||
'ai': ['ai', 'ollama', 'chatgpt', 'llm', 'machine-learning', 'ai-assistant'],
|
||||
'analytics': ['analytics', 'tracker', 'monitoring', 'graphs', 'metrics', 'bi', 'tableau',
|
||||
'graphite', 'profiling', 'tag manager', 'visualization', 'data', 'statistics', 'status',
|
||||
'tracking'],
|
||||
'automation': ['automation', 'scheduling', 'cron', 'zapier', 'homeautomation',
|
||||
'home', 'assistant'],
|
||||
'blog': ['blog', 'weblog', 'ghost', 'wordpress', 'comments'],
|
||||
'calendar': ['calendar', 'calendars', 'caldav', 'carddav', 'appointment', 'appointments',
|
||||
'events', 'schedule', 'scheduler', 'doodle', 'calendly', 'ics', 'groupware', 'contacts',
|
||||
'addressbook', 'meetings'],
|
||||
'chat': ['chat', 'webchat', 'slack', 'gitter', 'teams', 'messaging',
|
||||
'instant-messaging', 'livechat', 'chat-widget', 'irc', 'riot', 'matrix',
|
||||
'zulip', 'conversation', 'communication', 'social'],
|
||||
'crm': ['crm', 'salesforce', 'sugarcrm', 'suitecrm', 'prm', 'erp',
|
||||
'pipedrive', 'customer support', 'helpdesk', 'zendesk', 'helpscout',
|
||||
'support', 'tickets', 'ticketing software', 'help', 'service management',
|
||||
'dolibarr'],
|
||||
'document': ['document', 'documents', 'docs', 'office', 'office365',
|
||||
'googledocs', 'editor', 'ocr', 'signature', 'docusign', 'pandadoc',
|
||||
'signning', 'collaboration', 'collaborative', 'digital', 'pdf',
|
||||
'draw', 'sketch', 'whiteboard', 'design', 'figma', 'prototyping',
|
||||
'writing', 'excel'],
|
||||
'email': ['email', 'mail', 'newsletter', 'campaign', 'mailchimp',
|
||||
'sendgrid', 'sendinblue', 'webmail', 'imap', 'smtp', 'gmail',
|
||||
'fastmail', 'marketing', 'campaigns', 'listmonk'],
|
||||
'finance': ['finance', 'finances', 'accounting', 'money', 'invoices',
|
||||
'mint', 'gnucash', 'invoice', 'expense', 'quote', 'expences',
|
||||
'firefly', 'firefly-iii', 'actual', 'control', 'track',
|
||||
'shop', 'inventory'],
|
||||
'forum': ['forum', 'community', 'discourse', 'bb', 'phpbb', 'vanilla',
|
||||
'stackoverflow', 'q&a platform', 'feedback', 'feature requests',
|
||||
'canny', 'portal', 'userresponse', 'uservoice'],
|
||||
'gallery': ['gallery', 'photo', 'pictures', 'images', 'picasa',
|
||||
'photos', 'instagram', 'flickr', 'imagebin', 'screencloud'],
|
||||
'game': ['game', 'games', 'gaming', 'multiplayer'],
|
||||
'git': ['version control', 'git', 'code hosting', 'code', 'development',
|
||||
'github', 'bitbucket', 'gitlab', 'gitea', 'ci', 'cd', 'docker',
|
||||
'registry', 'harbor', 'devtools', 'build', 'npm', 'repository',
|
||||
'artifactory', 'devops', 'drone', 'actions', 'bash', 'powershell',
|
||||
'golang', 'rails', 'vuejs', 'paste', 'pastebin'],
|
||||
'hosting': ['hosting', 'fileserver', 'webserver', 'server', 'cms',
|
||||
'static', 'website', 'jekyll', 'pages', 'netlify', 'lamp', 'stacks',
|
||||
'apache', 'php', 'squarespace', 'wix', 'mysql', 'proxy', 'external',
|
||||
'heritage'],
|
||||
'media': ['media', 'streaming', 'video', 'audio', 'movies',
|
||||
'mediacenter', 'plex', 'netflix', 'music', 'subsonic', 'spotify',
|
||||
'last.fm', 'mp3', 'music player', 'podcast', 'audiobook', 'videos',
|
||||
'youtube', 'vimeo', 'rtmp', 'obs', 'livestream', 'broadcast',
|
||||
'stream', 'media server', 'books', 'ebook', 'ebooks', 'epub', 'mobi',
|
||||
'calibre', 'kindle', 'kobo', 'opds', 'comics', 'manga', 'goodreads',
|
||||
'gpodder', 'torrent', 'bittorrent', 'qbittorrent', 'vuetorrent'],
|
||||
'no-code': ['no-code', 'nocode', 'airtable', 'spreadsheet', 'database',
|
||||
'graphql', 'typeform', 'api', 'contentful', 'strapi', 'sql',
|
||||
'applications', 'automations', 'dashboards', 'collaborate',
|
||||
'survey', 'surveymonkey', 'qualtrics', 'polls', 'forms', 'chatbot',
|
||||
'converter', 'tools'],
|
||||
'notes': ['notes', 'personal', 'evernote', 'memo', 'keep', 'onenote',
|
||||
'bookmarks', 'todo', 'diary', 'markdown', 'bookmark',
|
||||
'bookmark-manager', 'ideas', 'feed', 'productivity',
|
||||
'archive', 'readlater', 'readability', 'pocket', 'instapaper',
|
||||
'delicous', 'recipe', 'cookbook', 'cooking', 'food', 'meal-planner',
|
||||
'household'],
|
||||
'project': ['project', 'management', 'kanban', 'task management',
|
||||
'trello', 'asana', 'jira', 'basecamp', 'agile', 'scrum', 'gantt',
|
||||
'timeline', 'backlog', 'planning', 'organize', 'tasks', 'notion',
|
||||
'project management', 'bug', 'issue', 'srints', 'wekan',
|
||||
'time', 'clockify', 'harvest', 'toggl', 'asset', 'device', 'it',
|
||||
'mdm', 'license tracking', 'assets management', 'software audit'],
|
||||
'rss': ['rss', 'atom', 'reader', 'greader', 'feedly', 'news',
|
||||
'news feeds', 'feeds', 'bridge'],
|
||||
'security': ['password', 'bitwarden', 'vaultwarden', 'lastpass',
|
||||
'1password', 'manager', 'encryption', 'key', 'secret', 'vault',
|
||||
'hashicorp', 'auth', 'sso', 'oidc', 'openid', 'saml', '2fa',
|
||||
'two-factor authentication', 'dns', 'adblock', 'pihole', 'ublock',
|
||||
'privacy', 'vpn', 'openvpn', 'network', 'wireguard',
|
||||
'keycloak'],
|
||||
'sync': ['sync', 'file sharing', 'files', 'dropbox', 'cloud', 'file',
|
||||
'sharing', 'storage', 's3', 'objectstore', 'sftp', 'ftp', 'webdav',
|
||||
'file browser', 'airdrop', 'filesharing', 'filemanager', 'share'],
|
||||
'voip': ['voip', 'voice', 'conference', 'zoom', 'call', 'webrtc',
|
||||
'meeting', 'web meeting', 'p2p', 'sfu', 'bbb', 'im', 'videochat'],
|
||||
'wiki': ['wiki', 'confluence', 'sharepoint', 'knowledgebase',
|
||||
'knowledge base'],
|
||||
};
|
||||
|
||||
async function onAppInstallDialogClose() {
|
||||
window.location.href = '#/appstore';
|
||||
}
|
||||
@@ -143,16 +242,21 @@ async function onHashChange() {
|
||||
const params = new URLSearchParams(window.location.hash.slice(window.location.hash.indexOf('?')));
|
||||
const version = params.get('version') || 'latest';
|
||||
|
||||
try {
|
||||
await appInstallDialog.value.open(appId, version, installedApps.value.length >= features.value.appMaxCount, domains.value);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
inputDialog.value.info({
|
||||
const [error, result] = await appstoreModel.get(appId, version);
|
||||
if (error || !result.manifest) {
|
||||
if (error) console.error(error);
|
||||
return inputDialog.value.info({
|
||||
title: t('appstore.appNotFoundDialog.title'),
|
||||
message: t('appstore.appNotFoundDialog.description', { appId, version }),
|
||||
confirmLabel: t('main.dialog.close'),
|
||||
});
|
||||
}
|
||||
|
||||
const packageData = {
|
||||
...result, // { id, creationDate, publishState, manifest, iconUrl }
|
||||
appStoreId: `${appId}@${version}`
|
||||
};
|
||||
appInstallDialog.value.open(packageData, installedApps.value.length >= features.value.appMaxCount, domains.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +282,7 @@ async function getDomains() {
|
||||
domains.value = result;
|
||||
}
|
||||
const applinkDialog = useTemplateRef('applinkDialog');
|
||||
const communityAppDialog = useTemplateRef('communityAppDialog');
|
||||
|
||||
function onAddAppLink() {
|
||||
applinkDialog.value.open();
|
||||
@@ -187,6 +292,14 @@ function onApplinkAdded() {
|
||||
window.location.href = '#/apps';
|
||||
}
|
||||
|
||||
function onInstallCommunityApp() {
|
||||
communityAppDialog.value.open();
|
||||
}
|
||||
|
||||
function onCommunityAppSuccess(packageData) {
|
||||
appInstallDialog.value.open(packageData, installedApps.value.length >= features.value.appMaxCount, domains.value);
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
setItemWidth();
|
||||
|
||||
@@ -222,13 +335,13 @@ onDeactivated(() => {
|
||||
<div ref="view" class="content-large" style="width: 100%; height: 100%;">
|
||||
<InputDialog ref="inputDialog"/>
|
||||
<ApplinkDialog ref="applinkDialog" @success="onApplinkAdded"/>
|
||||
<CommunityAppDialog ref="communityAppDialog" @success="onCommunityAppSuccess"/>
|
||||
<AppInstallDialog ref="appInstallDialog" @close="onAppInstallDialogClose"/>
|
||||
|
||||
<div class="filter-bar">
|
||||
<SingleSelect v-model="category" :options="categories" option-key="id" option-label="label" :disabled="!ready"/>
|
||||
<TextInput ref="searchInput" @keydown.esc="search = ''" v-model="search" :disabled="!ready" :placeholder="$t('appstore.searchPlaceholder')" style="flex-grow: 1;" autocomplete="off"/>
|
||||
<Button tool outline href="/#/appstore/io.cloudron.builtin.appproxy">Add app proxy</Button>
|
||||
<Button tool outline @click="onAddAppLink()">Add external link</Button>
|
||||
<Button tool :menu="addCustomAppMenu">{{ $t('appstore.action.addCustomApp') }} </Button>
|
||||
</div>
|
||||
|
||||
<div v-if="!ready" style="margin-top: 15px">
|
||||
@@ -279,6 +392,7 @@ onDeactivated(() => {
|
||||
.filter-bar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef, reactive, inject } from 'vue';
|
||||
import { ref, onMounted, useTemplateRef, reactive, inject, computed } from 'vue';
|
||||
import { Button, ProgressBar, InputDialog } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import ActionBar from '../components/ActionBar.vue';
|
||||
@@ -20,7 +20,7 @@ import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import TasksModel from '../models/TasksModel.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
|
||||
import { prettySchedule, prettySiteLocation } from '../utils.js';
|
||||
import { prettySchedule } from '../utils.js';
|
||||
|
||||
const profile = inject('profile');
|
||||
|
||||
@@ -34,6 +34,7 @@ const systemBackupList = useTemplateRef('systemBackupList');
|
||||
|
||||
const sites = ref([]);
|
||||
const busy = ref(true);
|
||||
const hasUpdateBackupSite = computed(() => sites.value.some(site => site.enableForUpdates));
|
||||
|
||||
const backupSiteAddDialog = useTemplateRef('backupSiteAddDialog');
|
||||
function onAdd() {
|
||||
@@ -296,10 +297,10 @@ onMounted(async () => {
|
||||
|
||||
<br/>
|
||||
|
||||
<div>
|
||||
<ProgressBar mode="indeterminate" v-if="busy" slim :show-label="false" />
|
||||
<div v-if="!busy && sites.length === 0" class="empty-placeholder">{{ $t('backup.sites.emptyPlaceholder') }}</div>
|
||||
<div class="backup-site" v-for="site in sites" :key="site.id">
|
||||
<ProgressBar mode="indeterminate" v-if="busy" slim :show-label="false" />
|
||||
<div class="warning-label" style="margin-bottom: 10px;" v-if="!busy && sites.length > 0 && !hasUpdateBackupSite">{{ $t('backup.sites.noAutomaticUpdateBackupWarning') }}</div>
|
||||
<div v-if="!busy && sites.length === 0" class="empty-placeholder">{{ $t('backup.sites.emptyPlaceholder') }}</div>
|
||||
<div class="backup-site" v-for="site in sites" :key="site.id">
|
||||
<div style="display: flex; align-items: start; margin-top: 6px;">
|
||||
<StateLED :busy="site.status.busy" :state="site.status.state"/>
|
||||
</div>
|
||||
@@ -317,12 +318,15 @@ onMounted(async () => {
|
||||
|
||||
<div>
|
||||
<b>Storage:</b> {{ site.provider }} ({{ site.format }})
|
||||
<span>at {{ prettySiteLocation(site) }}</span>
|
||||
<span>at {{ site.locationLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<b>Content:</b> <span v-html="prettyBackupContents(site.contents)"></span>
|
||||
</div>
|
||||
<div>
|
||||
<b>{{ $t('backups.configureBackupStorage.automaticUpdates.title') }}:</b> {{ site.enableForUpdates ? $t('main.dialog.yes') : $t('main.dialog.no') }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<b>{{ $t('backups.schedule.schedule') }}:</b> {{ prettySchedule(site.schedule) }}
|
||||
@@ -333,8 +337,8 @@ onMounted(async () => {
|
||||
<div class="backup-site-task">
|
||||
<div v-if="!site.task">
|
||||
<b>{{ $t('backup.sites.lastRun') }}:</b>
|
||||
<span v-if="site.taskLoaded">Never</span>
|
||||
<span v-else>...</span>
|
||||
<span v-if="site.taskLoaded"> Never</span>
|
||||
<span v-else> ...</span>
|
||||
</div>
|
||||
<div v-if="site.task && site.task.success"><b>{{ $t('backup.sites.lastRun') }}:</b> {{ prettyLongDate(site.task.ts) }}</div>
|
||||
<div v-if="site.task && site.task.error">
|
||||
@@ -344,19 +348,16 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 10px;" class="text-danger" v-if="site.status.message" v-html="site.status.message"></div>
|
||||
<div v-if="site.task && site.task.running">
|
||||
<div style="margin-top: 10px; display: flex; align-items: center; gap: 10px; overflow: hidden;">
|
||||
<div style="flex-grow: 1; overflow: hidden;">
|
||||
<ProgressBar :busy="true" :show-label="false" :value="site.task.percent" :mode="site.task.percent <= 0 ? 'indeterminate' : null" />
|
||||
<div style="display: block; margin-top: 3px; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ site.task.percent }}% {{ site.task.message }}</div>
|
||||
</div>
|
||||
<Button plain tool :href="`/logs.html?taskId=${site.task.id}`" target="_blank">Logs</Button>
|
||||
<Button danger plain tool icon="fa-solid fa-xmark" @click="onCancelTask(site.task.id)"></Button>
|
||||
<div v-if="site.task && site.task.running" style="margin-top: 10px; display: grid; grid-template-columns: 1fr auto auto; column-gap: 10px; align-items: center;">
|
||||
<div style="overflow: hidden;">
|
||||
<ProgressBar :busy="true" :show-label="false" :value="site.task.percent" :mode="site.task.percent <= 0 ? 'indeterminate' : null" />
|
||||
</div>
|
||||
<Button plain tool :href="`/logs.html?taskId=${site.task.id}`" target="_blank">Logs</Button>
|
||||
<Button danger plain tool icon="fa-solid fa-xmark" @click="onCancelTask(site.task.id)"></Button>
|
||||
<div style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ site.task.percent }}% {{ site.task.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed, inject } from 'vue';
|
||||
import { Button, TableView, TextInput, InputDialog } from '@cloudron/pankow';
|
||||
import { Button, TableView, TextInput, InputDialog, ProgressBar } from '@cloudron/pankow';
|
||||
import Certificates from '../components/Certificates.vue';
|
||||
import ActionBar from '../components/ActionBar.vue';
|
||||
import SyncDns from '../components/SyncDns.vue';
|
||||
@@ -113,7 +113,7 @@ async function refreshDomains() {
|
||||
|
||||
domains.value = result;
|
||||
|
||||
dashboardDomainComponent.value.updateDomains(result);
|
||||
dashboardDomainComponent.value?.updateDomains(result);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -150,11 +150,13 @@ onMounted(async () => {
|
||||
|
||||
<br/>
|
||||
|
||||
<ProgressBar v-if="busy" mode="indeterminate" :show-label="false" :slim="true" :show-track="false"/>
|
||||
|
||||
<TableView :model="filteredDomains" :columns="columns" :busy="busy" style="max-height: 450px;" :placeholder="$t(search ? 'domains.noMatchesPlaceholder' : 'domains.emptyPlaceholder')">
|
||||
<template #provider="domain">
|
||||
<template #provider="{ item:domain }">
|
||||
{{ DomainsModel.prettyProviderName(domain.provider) }}
|
||||
</template>
|
||||
<template #actions="domain">
|
||||
<template #actions="{ item:domain }">
|
||||
<ActionBar :actions="createActionMenu(domain)" />
|
||||
</template>
|
||||
</TableView>
|
||||
|
||||
@@ -6,6 +6,7 @@ const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef, inject } from 'vue';
|
||||
import { Button, ProgressBar, Checkbox, InputDialog, Dialog, FormGroup, Switch } from '@cloudron/pankow';
|
||||
import SaveIndicator from '../components/SaveIndicator.vue';
|
||||
import Section from '../components/Section.vue';
|
||||
import SettingsItem from '../components/SettingsItem.vue';
|
||||
import CatchAllSettingsItem from '../components/CatchAllSettingsItem.vue';
|
||||
@@ -114,6 +115,7 @@ async function onEnableIncoming() {
|
||||
|
||||
const customFrom = ref(false);
|
||||
const customFromBusy = ref(false);
|
||||
const customFromSaveIndicator = useTemplateRef('customFromSaveIndicator');
|
||||
|
||||
async function onToggleCustomFrom(value) {
|
||||
customFromBusy.value = true;
|
||||
@@ -122,9 +124,11 @@ async function onToggleCustomFrom(value) {
|
||||
if (error) {
|
||||
customFrom.value = !value; // revert back old value
|
||||
customFromBusy.value = false;
|
||||
customFromSaveIndicator.value.error();
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
customFromSaveIndicator.value.success();
|
||||
customFromBusy.value = false;
|
||||
}
|
||||
|
||||
@@ -294,6 +298,7 @@ onMounted(async () => {
|
||||
<div v-html="$t('email.customFrom.description')"></div>
|
||||
</FormGroup>
|
||||
<Switch v-model="customFrom" @change="onToggleCustomFrom" :disabled="customFromBusy"/>
|
||||
<SaveIndicator ref="customFromSaveIndicator"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { prettyDecimalSize, sleep } from '@cloudron/pankow/utils';
|
||||
import { prettyRelayProviderName } from '../utils.js';
|
||||
import { TextInput } from '@cloudron/pankow';
|
||||
import { TextInput, ProgressBar } from '@cloudron/pankow';
|
||||
import Section from '../components/Section.vue';
|
||||
import StateLED from '../components/StateLED.vue';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
@@ -13,6 +13,7 @@ const domainsModel = DomainsModel.create();
|
||||
const mailModel = MailModel.create();
|
||||
|
||||
const domains = ref([]);
|
||||
const busy = ref(true);
|
||||
|
||||
const searchFilter = ref('');
|
||||
|
||||
@@ -84,7 +85,10 @@ async function refreshUsage() {
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await domainsModel.list();
|
||||
if (error) return console.error(error);
|
||||
if (error) {
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
result.forEach(d => {
|
||||
d.loadingStatus = true;
|
||||
@@ -100,6 +104,7 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
domains.value = result;
|
||||
busy.value = false;
|
||||
|
||||
refreshStatus();
|
||||
refreshUsage();
|
||||
@@ -111,7 +116,7 @@ onMounted(async () => {
|
||||
<div class="content">
|
||||
<Section :title="$t('emails.domains.title')">
|
||||
<template #header-title-extra>
|
||||
<span style="font-weight: normal; font-size: 14px">({{ domains.length === 0 ? '-' : filteredDomains.length }})</span>
|
||||
<span style="font-weight: normal; font-size: 14px">({{ busy ? '-' : filteredDomains.length }})</span>
|
||||
</template>
|
||||
|
||||
<template #header-buttons>
|
||||
@@ -119,6 +124,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<ProgressBar v-if="busy" mode="indeterminate" :show-label="false" :slim="true" :show-track="false"/>
|
||||
<div v-if="domains.length !== 0 && filteredDomains.length === 0" class="email-placeholder">{{ $t('domains.noMatchesPlaceholder') }}</div>
|
||||
<a v-for="domain in filteredDomains" :key="domain.domain" :href="`#/email-domain/${domain.domain}`" class="email-domain">
|
||||
<div style="display: flex; align-items: center;">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, reactive, onMounted, watch, useTemplateRef, nextTick } from 'vue';
|
||||
import { Button, TextInput, MultiSelect } from '@cloudron/pankow';
|
||||
import { ref, reactive, computed, onMounted, watch, useTemplateRef, nextTick } from 'vue';
|
||||
import { Button, TextInput, MultiSelect, Popover, FormGroup, DateTimeInput } from '@cloudron/pankow';
|
||||
import { useDebouncedRef, prettyEmailAddresses, prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import MailModel from '../models/MailModel.js';
|
||||
|
||||
@@ -21,17 +21,98 @@ const availableTypes = [
|
||||
|
||||
const refreshBusy = ref(false);
|
||||
const eventlogs = ref([]);
|
||||
const search = useDebouncedRef('');
|
||||
const page = ref(1);
|
||||
const perPage = ref(10);
|
||||
const types = reactive([]);
|
||||
const perPage = ref(100);
|
||||
const eventlogContainer = useTemplateRef('eventlogContainer');
|
||||
// eslint-disable-next-line prefer-const
|
||||
let types = reactive([]);
|
||||
|
||||
const filterFrom = ref('');
|
||||
const filterTo = ref('');
|
||||
const dateFilterPopover = useTemplateRef('dateFilterPopover');
|
||||
const dateFilterButton = useTemplateRef('dateFilterButton');
|
||||
|
||||
const highlight = useDebouncedRef('', 300);
|
||||
const currentMatchPosition = ref(-1);
|
||||
const searching = ref(false);
|
||||
const SEARCH_LOOKAHEAD_PAGES = 5;
|
||||
|
||||
function isMatch(eventlog, term) {
|
||||
if (!term) return false;
|
||||
const t = term.toLowerCase();
|
||||
const fields = [
|
||||
prettyEmailAddresses(eventlog.mailFrom),
|
||||
prettyEmailAddresses(eventlog.rcptTo),
|
||||
eventlog.mailbox,
|
||||
eventlog.type,
|
||||
eventlog.message,
|
||||
eventlog.reason,
|
||||
JSON.stringify(eventlog),
|
||||
];
|
||||
return fields.some(f => f && String(f).toLowerCase().includes(t));
|
||||
}
|
||||
|
||||
const matchIndices = computed(() => {
|
||||
if (!highlight.value) return [];
|
||||
return eventlogs.value.reduce((acc, e, i) => {
|
||||
if (isMatch(e, highlight.value)) acc.push(i);
|
||||
return acc;
|
||||
}, []);
|
||||
});
|
||||
|
||||
function scrollToIndex(idx) {
|
||||
const el = eventlogContainer.value?.querySelector(`[data-index="${idx}"]`);
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
}
|
||||
|
||||
function goToPrevMatch() {
|
||||
if (currentMatchPosition.value > 0) {
|
||||
currentMatchPosition.value--;
|
||||
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
|
||||
}
|
||||
}
|
||||
|
||||
async function goToNextMatch() {
|
||||
if (!highlight.value || searching.value) return;
|
||||
|
||||
if (currentMatchPosition.value + 1 < matchIndices.value.length) {
|
||||
currentMatchPosition.value++;
|
||||
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
|
||||
return;
|
||||
}
|
||||
|
||||
searching.value = true;
|
||||
let endOfLog = false;
|
||||
for (let i = 0; i < SEARCH_LOOKAHEAD_PAGES; i++) {
|
||||
const prevLength = eventlogs.value.length;
|
||||
await fetchMore();
|
||||
if (eventlogs.value.length === prevLength) { endOfLog = true; break; }
|
||||
|
||||
if (currentMatchPosition.value + 1 < matchIndices.value.length) {
|
||||
currentMatchPosition.value++;
|
||||
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
|
||||
searching.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
searching.value = false;
|
||||
if (endOfLog) window.pankow.notify({ text: `No more matches for "${highlight.value}".`, timeout: 3000 });
|
||||
else window.pankow.notify({ text: `No match found for "${highlight.value}" in ${eventlogs.value.length} entries. Click next to keep searching.`, timeout: 3000 });
|
||||
}
|
||||
|
||||
function buildFromTo() {
|
||||
const from = filterFrom.value ? new Date(filterFrom.value + 'T00:00:00').toISOString() : undefined;
|
||||
const to = filterTo.value ? new Date(filterTo.value + 'T23:59:59.999').toISOString() : undefined;
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
highlight.value = '';
|
||||
refreshBusy.value = true;
|
||||
page.value = 1;
|
||||
|
||||
const [error, result] = await mailModel.eventlog(types.join(','), search.value, page.value, perPage.value);
|
||||
const { from, to } = buildFromTo();
|
||||
const [error, result] = await mailModel.eventlog(types.join(','), '', page.value, perPage.value, from, to);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = result;
|
||||
@@ -48,7 +129,8 @@ async function onRefresh() {
|
||||
async function fetchMore() {
|
||||
page.value++;
|
||||
|
||||
const [error, result] = await mailModel.eventlog(types.join(','), search.value, page.value, perPage.value);
|
||||
const { from, to } = buildFromTo();
|
||||
const [error, result] = await mailModel.eventlog(types.join(','), '', page.value, perPage.value, from, to);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = eventlogs.value.concat(result);
|
||||
@@ -58,9 +140,24 @@ async function onScroll(event) {
|
||||
if (event.target.scrollTop + event.target.clientHeight >= event.target.scrollHeight) await fetchMore();
|
||||
}
|
||||
|
||||
function onOpenDateFilter(event) {
|
||||
dateFilterPopover.value.open(event, dateFilterButton.value.$el || dateFilterButton.value);
|
||||
}
|
||||
|
||||
watch(perPage, onRefresh);
|
||||
watch(types, onRefresh);
|
||||
watch(search, onRefresh);
|
||||
watch(filterFrom, onRefresh);
|
||||
watch(filterTo, onRefresh);
|
||||
watch(highlight, async () => {
|
||||
if (matchIndices.value.length > 0) {
|
||||
currentMatchPosition.value = 0;
|
||||
await nextTick();
|
||||
scrollToIndex(matchIndices.value[0]);
|
||||
} else {
|
||||
currentMatchPosition.value = -1;
|
||||
if (highlight.value) goToNextMatch();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await onRefresh();
|
||||
@@ -80,12 +177,27 @@ onMounted(async () => {
|
||||
<h2 class="section-header">
|
||||
{{ $t('emails.eventlog.title') }}
|
||||
<div>
|
||||
<TextInput :placeholder="$t('main.searchPlaceholder')" style="flex-grow: 1;" v-model="search"/>
|
||||
<TextInput placeholder="Highlight..." v-model="highlight" @keydown.enter="goToNextMatch()"/>
|
||||
<Button tool plain :disabled="!highlight || currentMatchPosition <= 0 || searching" @click="goToPrevMatch()" icon="fa-solid fa-chevron-up" />
|
||||
<Button tool plain :disabled="!highlight || searching" :loading="searching" @click="goToNextMatch()" icon="fa-solid fa-chevron-down" />
|
||||
<Button tool secondary ref="dateFilterButton" @click="onOpenDateFilter($event)" :icon="(filterFrom || filterTo) ? 'fa-solid fa-calendar-check' : 'fa-solid fa-calendar'" />
|
||||
<MultiSelect v-model="types" :options="availableTypes" option-key="id" option-label="name" :selected-label="types.length ? $t('main.multiselect.selected', { n: types.length }) : $t('emails.typeFilterHeader')"/>
|
||||
<Button tool secondary @click="onRefresh()" :loading="refreshBusy" icon="fa-solid fa-sync-alt" />
|
||||
<Button tool secondary href="/logs.html?id=mail" target="_blank">{{ $t('main.action.logs') }}</Button>
|
||||
</div>
|
||||
</h2>
|
||||
<Popover ref="dateFilterPopover" width="300px">
|
||||
<div style="padding: 15px; display: flex; flex-direction: column; gap: 10px;">
|
||||
<FormGroup>
|
||||
<label>From</label>
|
||||
<DateTimeInput date-only v-model="filterFrom" :max="filterTo || undefined" />
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<label>To</label>
|
||||
<DateTimeInput date-only v-model="filterTo" :min="filterFrom || undefined" />
|
||||
</FormGroup>
|
||||
</div>
|
||||
</Popover>
|
||||
<div class="section-body" ref="eventlogContainer" style="margin-top: 16px; overflow: auto; padding-top: 0" @scroll="onScroll">
|
||||
<table class="eventlog-table">
|
||||
<thead>
|
||||
@@ -98,8 +210,8 @@ onMounted(async () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="eventlog in eventlogs" :key="eventlog._id">
|
||||
<tr @click="eventlog.isOpen = !eventlog.isOpen" :class="{ 'active': eventlog.isOpen }" >
|
||||
<template v-for="(eventlog, index) in eventlogs" :key="eventlog._id">
|
||||
<tr :data-index="index" @click="eventlog.isOpen = !eventlog.isOpen" :class="{ 'active': eventlog.isOpen, 'eventlog-match': highlight && isMatch(eventlog, highlight), 'eventlog-match-current': matchIndices[currentMatchPosition] === index }" >
|
||||
<td>
|
||||
<i class="fas fa-arrow-circle-left" v-if="eventlog.type === 'sent'" v-tooltip="$t('emails.eventlog.type.outgoing')"></i>
|
||||
<i class="fas fa-history" v-if="eventlog.type === 'deferred'" v-tooltip="$t('emails.eventlog.type.deferred')"></i>
|
||||
@@ -144,3 +256,66 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.eventlog-table {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
border-spacing: 0px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.elide-table-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.eventlog-table thead {
|
||||
background-color: var(--pankow-body-background-color);
|
||||
top: 0;
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.eventlog-table th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.active,
|
||||
.eventlog-table tbody tr:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-table th,
|
||||
.eventlog-table td {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.eventlog-details {
|
||||
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
|
||||
cursor: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.eventlog-details pre {
|
||||
white-space: pre-wrap;
|
||||
color: var(--pankow-text-color);
|
||||
font-size: 13px;
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.eventlog-match {
|
||||
background-color: rgba(255, 193, 7, 0.15);
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.eventlog-match-current {
|
||||
background-color: rgba(255, 193, 7, 0.35);
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user