Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b810ec74f | ||
|
|
f59b9e1b5f | ||
|
|
398dbe802e | ||
|
|
8b5fa0fe76 | ||
|
|
99042a47f3 | ||
|
|
46e600abe9 | ||
|
|
051dd8b58f | ||
|
|
067b02dba1 | ||
|
|
22a0874188 | ||
|
|
0e25809158 | ||
|
|
305d877896 | ||
|
|
a932a5251a | ||
|
|
7b58fccb9f | ||
|
|
859fef62d4 | ||
|
|
0647a3a233 | ||
|
|
aedf55dba0 | ||
|
|
e9a422b657 | ||
|
|
23df6bdfbf | ||
|
|
1b5fee233e | ||
|
|
63457d2de4 | ||
|
|
732c944e98 | ||
|
|
86c4db8f22 | ||
|
|
8c0c9981de | ||
|
|
e5dcf78ceb | ||
|
|
92bce26e22 | ||
|
|
a72c038435 | ||
|
|
6742cdf373 | ||
|
|
ea72cef7f9 | ||
|
|
565ad83399 | ||
|
|
43f795c9e4 | ||
|
|
1589cfb639 | ||
|
|
a9b9931aa8 | ||
|
|
1cd577cc65 | ||
|
|
13d8db3daa | ||
|
|
40c4a01bc0 | ||
|
|
4301c70ba7 | ||
|
|
d5e9e556ab | ||
|
|
bdf9e04963 | ||
|
|
b95285365d | ||
|
|
abf445e969 | ||
|
|
e988e3a303 | ||
|
|
dca548b8a0 | ||
|
|
56ecfdb4eb | ||
|
|
7640851aa9 | ||
|
|
d9301160e1 | ||
|
|
3656d7f631 | ||
|
|
9f89b07777 | ||
|
|
199dbff7b1 | ||
|
|
88b8cb48fc | ||
|
|
e8b3232966 | ||
|
|
5de7537c71 | ||
|
|
4706313239 | ||
|
|
d32819da4e | ||
|
|
b6becae396 | ||
|
|
d310c5746e | ||
|
|
e2f4e9f30a | ||
|
|
44011afd14 | ||
|
|
cebaa71ce1 | ||
|
|
0ed9105a05 | ||
|
|
69ecbe5ad7 | ||
|
|
a218761e99 | ||
|
|
71d167d5fb | ||
|
|
aabdea8627 | ||
|
|
f220a1384c | ||
|
|
e438ade08e | ||
|
|
ed1d537f60 | ||
|
|
d59bc05f12 | ||
|
|
4608301f1c | ||
|
|
a865320e3a | ||
|
|
bc8c01900b | ||
|
|
9704eefc21 | ||
|
|
52cd52d83c | ||
|
|
4a29371907 | ||
|
|
1e5e4e3189 | ||
|
|
041f7da59b | ||
|
|
4dae3447d6 | ||
|
|
7391af6f08 | ||
|
|
8a640c8219 | ||
|
|
2857582f46 | ||
|
|
1d80f03c38 | ||
|
|
d7c20048fe | ||
|
|
cbbdb77a6e | ||
|
|
2ff995aa95 | ||
|
|
21705a0e96 | ||
|
|
c03da3be54 | ||
|
|
69f48ed11a | ||
|
|
caa0c342a4 | ||
|
|
01b4388b3c | ||
|
|
b870f98ec2 | ||
|
|
a5249102f2 | ||
|
|
5aa0c57a74 | ||
|
|
053b076af0 | ||
|
|
247309e11b | ||
|
|
c9fe08e7b7 | ||
|
|
468d4dd9b0 | ||
|
|
6056ba6475 | ||
|
|
4f03a6fb58 | ||
|
|
d8aa4bc5e4 | ||
|
|
06e46e0f1e | ||
|
|
731295f708 | ||
|
|
9399040cd3 | ||
|
|
9f9fde5811 | ||
|
|
cbc46a8229 | ||
|
|
fb11997430 | ||
|
|
b6fbc46b58 | ||
|
|
21de2513e7 | ||
|
|
51bb2d2bc2 |
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 13
|
||||
},
|
||||
"rules": {
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"single"
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"no-console": "off"
|
||||
}
|
||||
}
|
||||
34
CHANGES
34
CHANGES
@@ -2814,3 +2814,37 @@
|
||||
* sshfs: if remote copy fails, fallback to sshfs based copy
|
||||
* frontend: reduce DOM node creation on very fast logstreams and cap to 1k loglines
|
||||
|
||||
[8.0.3]
|
||||
* logs: fix recursion when displaying box logs
|
||||
* frontend: fix clear view in logs viewer
|
||||
* dashboard: support links/markdown in checklist items
|
||||
|
||||
[8.0.4]
|
||||
* ami: IMDv2 support
|
||||
* ionos: add contract-owned eu-central-3
|
||||
* dashboard: remove mailbox import/export feature
|
||||
* backupcleaner: do not remove the backup in progress
|
||||
* backups: make noop upload work again
|
||||
* volumes: `/mnt/volumes` is reserved
|
||||
* apps: do not log app logs to output
|
||||
* sftp: restore mode and owner
|
||||
* dashboard: also render checklist items in apps.html
|
||||
|
||||
[8.0.5]
|
||||
* cpu quota: fix rounding error
|
||||
* frontend: fix translation resolver to actually fallback to english
|
||||
* i18n: fix crash if language file is missing
|
||||
* memory: fix slider UI where max was incorrectly set
|
||||
* digitalocean: add LON1 Spaces region
|
||||
* exoscale: add sos AT-VIE-2 region
|
||||
* i18n: remove use of "Cloudron"
|
||||
* tz: add note in backup and update UI
|
||||
* backups: do not overflow the schedule timings
|
||||
* checklist: new checklist items on update are acknowledged
|
||||
* backups: automatically trigger a remount if mount is not active
|
||||
* logs: rework the syslog parser
|
||||
* docker: use system dns for app containers
|
||||
* logs: show error message in UI when log rotated
|
||||
* unbound: prefer ip4 for dns queries (only on ubuntu 24 and above)
|
||||
* apps: allow operators to update apps
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@ const REGIONS_WASABI = [
|
||||
const REGIONS_DIGITALOCEAN = [
|
||||
{ name: 'AMS3', value: 'https://ams3.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' },
|
||||
@@ -181,6 +182,7 @@ const REGIONS_DIGITALOCEAN = [
|
||||
// https://www.exoscale.com/datacenters/
|
||||
const REGIONS_EXOSCALE = [
|
||||
{ name: 'Vienna (AT-VIE-1)', value: 'https://sos-at-vie-1.exo.io' },
|
||||
{ name: 'Vienna (AT-VIE-2)', value: 'https://sos-at-vie-2.exo.io' },
|
||||
{ name: 'Sofia (BG-SOF-1)', value: 'https://sos-bg-sof-1.exo.io' },
|
||||
{ name: 'Zurich (CH-DK-2)', value: 'https://sos-ch-dk-2.exo.io' },
|
||||
{ name: 'Geneva (CH-GVA-2)', value: 'https://sos-ch-gva-2.exo.io' },
|
||||
@@ -240,9 +242,10 @@ const ENDPOINTS_OVH = [
|
||||
|
||||
// https://docs.ionos.com/cloud/managed-services/s3-object-storage/endpoints
|
||||
const REGIONS_IONOS = [
|
||||
{ name: 'Frankfurt (DE)', value: 'https://s3-de-central.profitbricks.com', region: 's3-de-central' }, // default
|
||||
{ name: 'Berlin (eu-central-2)', value: 'https://s3-eu-central-2.ionoscloud.com', region: 'eu-central-2' }, // default
|
||||
{ name: 'Logrono (eu-south-2)', value: 'https://s3-eu-south-2.ionoscloud.com', region: 'eu-south-2' }, // default
|
||||
{ name: 'Berlin (eu-central-3)', value: 'https://s3.eu-central-3.ionoscloud.com', region: 'de' }, // default. contract-owned
|
||||
{ name: 'Frankfurt (DE)', value: 'https://s3.eu-central-1.ionoscloud.com', region: 'de' },
|
||||
{ name: 'Berlin (eu-central-2)', value: 'https://s3-eu-central-2.ionoscloud.com', region: 'eu-central-2' },
|
||||
{ name: 'Logrono (eu-south-2)', value: 'https://s3-eu-south-2.ionoscloud.com', region: 'eu-south-2' },
|
||||
];
|
||||
|
||||
// this is not used anywhere because upcloud needs endpoint URL. we detect region from the URL (https://upcloud.com/data-centres)
|
||||
@@ -468,8 +471,6 @@ function redirectIfNeeded(status, currentView) {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(status, currentView);
|
||||
|
||||
if (status.activated) {
|
||||
console.log('Already activated');
|
||||
if (currentView === 'dashboard') {
|
||||
@@ -1399,8 +1400,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
};
|
||||
|
||||
Client.prototype.getUpdateInfo = function (callback) {
|
||||
if (!this._userInfo.isAtLeastAdmin) return callback(new Error('Not allowed'));
|
||||
|
||||
get('/api/v1/updater/updates', null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 200) return callback(new ClientError(status, data));
|
||||
@@ -2643,9 +2642,10 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
this.config(function (error, result) {
|
||||
if (error) return callback(error);
|
||||
|
||||
that.getUpdateInfo(function (error, info) { // note: non-admin users may get access denied for this
|
||||
if (!error) result.update = info.update; // attach update information to config object
|
||||
that.getUpdateInfo(function (error, info) {
|
||||
if (error) return callback(error);
|
||||
|
||||
result.update = info.update;
|
||||
that.setConfig(result);
|
||||
callback(null);
|
||||
});
|
||||
|
||||
@@ -713,6 +713,10 @@ multiselect {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.checklist-item > span > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Mail view
|
||||
// ----------------------------
|
||||
@@ -1755,6 +1759,7 @@ div:hover > .picture-edit-indicator {
|
||||
|
||||
.notification-item {
|
||||
cursor: pointer;
|
||||
padding: 10px 15px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 27px rgba(0,0,0,.1);
|
||||
|
||||
@@ -22,13 +22,17 @@
|
||||
"auth": {
|
||||
"sso": "Log ind med Cloudron-oplysninger",
|
||||
"nosso": "Log ind med en dedikeret konto",
|
||||
"email": "Log ind med din e-mailadresse"
|
||||
"email": "Log ind med din e-mailadresse",
|
||||
"openid": "Log ind med Cloudron OpenID"
|
||||
},
|
||||
"addAppAction": "Tilføj app",
|
||||
"addAppproxyAction": "Tilføj app-proxy",
|
||||
"addApplinkAction": "Tilføj app-link",
|
||||
"filter": {
|
||||
"clearAll": "Ryd alt"
|
||||
},
|
||||
"apps": {
|
||||
"count": "Antal apps: {{ count }}"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
@@ -80,7 +84,8 @@
|
||||
"justNow": "lige nu",
|
||||
"yeserday": "I går",
|
||||
"minutesAgo": "{{ m }} minutter siden",
|
||||
"hoursAgo": "{{ h }} timer siden"
|
||||
"hoursAgo": "{{ h }} timer siden",
|
||||
"never": "Aldrig"
|
||||
},
|
||||
"navbar": {
|
||||
"users": "Brugere"
|
||||
@@ -165,7 +170,10 @@
|
||||
"loginAction": "Login",
|
||||
"createAccountAction": "Opret konto",
|
||||
"switchToSignUpAction": "Har du ikke en konto endnu? Tilmeld dig",
|
||||
"switchToLoginAction": "Har du allerede en konto? Log ind"
|
||||
"switchToLoginAction": "Har du allerede en konto? Log ind",
|
||||
"setupWithTokenAction": "Opsætning",
|
||||
"setupToken": "Opsætningstoken",
|
||||
"titleToken": "Tilmeld dig med installationstoken"
|
||||
},
|
||||
"title": "App Store",
|
||||
"searchPlaceholder": "Søg efter alternativer som Github, Dropbox, Slack, Trello, …",
|
||||
@@ -180,7 +188,7 @@
|
||||
"users": {
|
||||
"externalLdap": {
|
||||
"title": "Tilslut en ekstern mappe",
|
||||
"description": "Cloudron synkroniserer brugere og grupper fra en ekstern LDAP- eller ActiveDirectory-server. Adgangskodebekræftelse til autentificering af disse brugere foretages mod den eksterne server. Synkroniseringen køres ikke automatisk, men skal udløses manuelt.",
|
||||
"description": "Denne indstilling synkroniserer og godkender brugere og grupper fra en ekstern LDAP- eller Active Directory-server. Synkroniseringen køres med jævne mellemrum, men kan også udløses manuelt.",
|
||||
"bindUsername": "Bind DN/Benyttelsesnavn (valgfrit)",
|
||||
"subscriptionRequiredAction": "Oprettelse af abonnement nu",
|
||||
"noopInfo": "LDAP-godkendelse er ikke konfigureret.",
|
||||
@@ -195,14 +203,15 @@
|
||||
"groupFilter": "Gruppefilter",
|
||||
"groupnameField": "Groupname Felt",
|
||||
"auth": "Auth",
|
||||
"autocreateUsersOnLogin": "Opret automatisk brugere, når de logger ind på Cloudron",
|
||||
"autocreateUsersOnLogin": "Opret automatisk brugere ved login",
|
||||
"showLogsAction": "Vis logs",
|
||||
"syncAction": "Synkroniser",
|
||||
"configureAction": "Konfigurer",
|
||||
"bindPassword": "Bind adgangskode (valgfrit)",
|
||||
"errorSelfSignedCert": "Serveren bruger et ugyldigt eller selvsigneret certifikat.",
|
||||
"providerOther": "Andre",
|
||||
"providerDisabled": "Deaktiveret"
|
||||
"providerDisabled": "Deaktiveret",
|
||||
"disableWarning": "Godkendelseskilden for alle eksisterende brugere bliver nulstillet til at godkende mod den lokale adgangskodedatabase."
|
||||
},
|
||||
"addUserDialog": {
|
||||
"sendInviteCheckbox": "Send en e-mail med en invitation nu",
|
||||
@@ -227,7 +236,9 @@
|
||||
"primaryEmail": "Primær e-mail",
|
||||
"errorDisplayNameRequired": "Navn er påkrævet",
|
||||
"activeCheckbox": "Brugeren er aktiv",
|
||||
"displayNamePlaceholder": "Valgfrit. Hvis den ikke er angivet, kan brugeren angive den under tilmeldingen"
|
||||
"displayNamePlaceholder": "Valgfrit. Hvis den ikke er angivet, kan brugeren angive den under tilmeldingen",
|
||||
"external2FA": "2FA-opsætning styres af ekstern godkendelseskilde",
|
||||
"ldapGroups": "LDAP-grupper"
|
||||
},
|
||||
"invitationDialog": {
|
||||
"descriptionLink": "Kopier link til invitation",
|
||||
@@ -255,10 +266,11 @@
|
||||
"description": "Cloudron kan fungere som en central brugerkatalogserver for eksterne programmer.",
|
||||
"enabled": "Aktiveret",
|
||||
"ipRestriction": {
|
||||
"description": "Mappeserveren kan begrænses til bestemte IP'er eller områder.",
|
||||
"description": "Begræns adgang til Directory Server til specifikke IP'er eller områder. Linjer, der starter med <code>#</code>, behandles som kommentarer.",
|
||||
"placeholder": "Linjeadskilt IP-adresse eller undernet",
|
||||
"label": "Begræns adgang"
|
||||
}
|
||||
},
|
||||
"cloudflarePortWarning": "Cloudflare-proxying skal deaktiveres på dashboard-domænet for at få adgang til LDAP-serveren"
|
||||
},
|
||||
"userImportDialog": {
|
||||
"description": "Upload en JSON- eller CSV-fil med det skema, der er beskrevet i vores <a href=\"{{ docsLink }}\" target=\"_blank\">dokumentation</a>",
|
||||
@@ -482,7 +494,10 @@
|
||||
"changeEmail": {
|
||||
"title": "Ændre primær e-mailadresse",
|
||||
"errorEmailInvalid": "E-mail-adressen er ikke gyldig",
|
||||
"errorEmailRequired": "En gyldig e-mailadresse er påkrævet"
|
||||
"errorEmailRequired": "En gyldig e-mailadresse er påkrævet",
|
||||
"email": "Ny e-mailadresse",
|
||||
"password": "Adgangskode til bekræftelse",
|
||||
"errorWrongPassword": "Forkert adgangskode"
|
||||
},
|
||||
"changeDisplayName": {
|
||||
"title": "Ændre dit visningsnavn",
|
||||
@@ -609,7 +624,7 @@
|
||||
},
|
||||
"check": {
|
||||
"noop": "Cloudron-backups er deaktiveret. Sørg for, at der tages backup af denne server ved hjælp af alternative midler. Se https://docs.cloudron.io/backups/#storage-providers for flere oplysninger.",
|
||||
"sameDisk": "Cloudron-backups er i øjeblikket på den samme disk som Cloudron-serverinstansen. Dette er farligt og kan føre til fuldstændigt tab af data, hvis disken fejler. Se https://docs.cloudron.io/backups/#storage-providers for lagring af sikkerhedskopier på en ekstern placering."
|
||||
"sameDisk": "Sikkerhedskopierne ligger i øjeblikket på den samme disk som Cloudron selv. Hvis disken fyldes op med disse sikkerhedskopier, vil Cloudron ikke fungere. En diskfejl kan også føre til fuldstændigt datatab. Se https://docs.cloudron.io/backups/#storage-providers for at gemme sikkerhedskopier på et eksternt sted."
|
||||
},
|
||||
"title": "Sikkerhedskopiering",
|
||||
"logs": {
|
||||
@@ -642,7 +657,9 @@
|
||||
"logo": "Logo",
|
||||
"changeLogo": {
|
||||
"title": "Vælg Cloudron Avatar"
|
||||
}
|
||||
},
|
||||
"backgroundImage": "Baggrundsbillede af login-side",
|
||||
"clearBackgroundImage": "Klar"
|
||||
},
|
||||
"emails": {
|
||||
"domains": {
|
||||
@@ -771,7 +788,7 @@
|
||||
"ip": {
|
||||
"interfaceDescription": "Liste over tilgængelige enheder på serveren med:",
|
||||
"title": "IP-adresse",
|
||||
"description": "Cloudron bruger denne IP-adresse, når der oprettes DNS-poster.",
|
||||
"description": "Cloudron bruger denne IPv4-adresse til at oprette DNS A-poster.",
|
||||
"provider": "Udbyder",
|
||||
"interface": "Navn på netværksgrænseflade",
|
||||
"configure": "Konfigurer",
|
||||
@@ -795,7 +812,7 @@
|
||||
},
|
||||
"title": "Netværk",
|
||||
"configureIp": {
|
||||
"title": "Konfigurer IP-provider",
|
||||
"title": "Konfigurer IPv4-provider",
|
||||
"providerGenericDescription": "Serverens offentlige IP-adresse registreres automatisk."
|
||||
},
|
||||
"ipv4": {
|
||||
@@ -838,14 +855,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"timezone": {
|
||||
"description": "Den aktuelle tidszoneindstilling er <b>{{{{ timeZone }}}</b>.\nDenne indstilling bruges til planlægning af backup- og opdateringsopgaver.",
|
||||
"description": "Den aktuelle tidszoneindstilling er <b>{{ timeZone }}</b>. Denne indstilling bruges til at planlægge backup- og opdateringsopgaver. Tidsstempler i brugergrænsefladen vises altid i browserens tidszone.",
|
||||
"title": "Tidszone"
|
||||
},
|
||||
"updates": {
|
||||
"updateAvailableAction": "Opdatering tilgængelig",
|
||||
"title": "Opdateringer",
|
||||
"autoUpdateDisabled": "Automatisk opdatering af platformen og apps er<b>deaktiveret</b>.",
|
||||
"currentSchedule": "Den nuværende tidsplan for automatisk opdatering af platform og apps er",
|
||||
"version": "Platform version",
|
||||
"showLogsAction": "Vis logs",
|
||||
"changeScheduleAction": "Ændre tidsplan",
|
||||
@@ -940,7 +955,11 @@
|
||||
"disableAction": "Deaktivere SSH-støtteadgang",
|
||||
"enableAction": "Aktiver SSH-støtteadgang"
|
||||
},
|
||||
"title": "Støtte"
|
||||
"title": "Støtte",
|
||||
"help": {
|
||||
"title": "Hjælp",
|
||||
"description": "Brug venligst følgende ressourcer til hjælp og support:\n* [Cloudron Forum]({{ forumLink }}) - Brug venligst de support- og app-specifikke kategorier til spørgsmål.\n* [Cloudron Docs & Knowledge Base]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"diskUsage": {
|
||||
@@ -964,7 +983,19 @@
|
||||
"title": "System Memory",
|
||||
"graphSubtext": "Kun apps, der bruger mere end {{ threshold }} af memory, vises"
|
||||
},
|
||||
"selectPeriodLabel": "Vælg periode"
|
||||
"selectPeriodLabel": "Vælg periode",
|
||||
"info": {
|
||||
"platformVersion": "Platformsversion",
|
||||
"title": "Info",
|
||||
"vendor": "Leverandør",
|
||||
"product": "Produtk",
|
||||
"memory": "Memory",
|
||||
"uptime": "Driftstid",
|
||||
"activationTime": "Cloudrons skabelsestidspunkt"
|
||||
},
|
||||
"graphs": {
|
||||
"title": "Diagrammer"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"renewCerts": {
|
||||
@@ -1025,7 +1056,13 @@
|
||||
"porkbunSecretapikey": "Hemmelig API-nøgle",
|
||||
"cloudflareDefaultProxyStatus": "Aktiver proxying for nye DNS-poster",
|
||||
"porkbunApikey": "API-nøgle",
|
||||
"bunnyAccessKey": "Bunny Access Key"
|
||||
"bunnyAccessKey": "Bunny Access Key",
|
||||
"deSecToken": "deSEC Token",
|
||||
"dnsimpleAccessToken": "Adgangstoken",
|
||||
"ovhEndpoint": "Endepunkt",
|
||||
"ovhConsumerKey": "Consumer Key",
|
||||
"ovhAppKey": "Application Key",
|
||||
"ovhAppSecret": "Application Secret"
|
||||
},
|
||||
"title": "Domæner og certs",
|
||||
"addDomain": "Tilføj domæne",
|
||||
@@ -1089,7 +1126,8 @@
|
||||
"copy": "Kopier",
|
||||
"clear": "Klar",
|
||||
"pasteInfo": "Brug Ctrl+v til at indsætte for at indsætte"
|
||||
}
|
||||
},
|
||||
"uploadTo": "Upload til {{ path }}"
|
||||
},
|
||||
"filemanager": {
|
||||
"newFileDialog": {
|
||||
@@ -1121,7 +1159,8 @@
|
||||
"renameDialog": {
|
||||
"title": "Omdøb {{ fileName }}",
|
||||
"newName": "Nyt navn",
|
||||
"rename": "Omdøb"
|
||||
"rename": "Omdøb",
|
||||
"reallyOverwrite": "Der findes allerede en fil med det navn. Overskrive eksisterende fil?"
|
||||
},
|
||||
"extractDialog": {
|
||||
"title": "Udpakning af {{ fileName }}",
|
||||
@@ -1278,7 +1317,7 @@
|
||||
},
|
||||
"enableEmailDialog": {
|
||||
"description": "Dette vil konfigurere Cloudron til at modtage e-mails for<b>{{ domain }}</b>Se dokumentationen for åbning af de <a href=\"{{{ requiredPortsDocsLink }}\" target=\"_blank\">forpligtede porte</a> for Cloudron Email.",
|
||||
"cloudflareInfo": "Domænet <code>{{{ adminDomain }}</code> administreres af Cloudflare. Kontroller venligst, at Cloudflare-proxying er deaktiveret for <code>{{{ mailFqdn }}</code> og indstillet til <code>Kun DNS</code>. Dette er påkrævet, fordi Cloudflare ikke giver proxy for e-mail.",
|
||||
"cloudflareInfo": "Mailserverens domæne <code>{{ adminDomain }}</code> administreres af Cloudflare. Kontrollér, at Cloudflare-proxy er deaktiveret for <code>{{ mailFqdn }}</code> og indstillet til <code>kun DNS</code>. Dette er nødvendigt, fordi Cloudflare ikke proxy'er e-mail.",
|
||||
"title": "Aktiver e-mail for {{ domain }}?",
|
||||
"noProviderInfo": "Der er ikke oprettet nogen DNS-udbyder. De DNS-poster, der er anført i fanen Status, skal oprettes manuelt.",
|
||||
"setupDnsCheckbox": "Opsæt Mail DNS-poster nu",
|
||||
@@ -1431,7 +1470,7 @@
|
||||
},
|
||||
"memory": {
|
||||
"title": "Memory graense",
|
||||
"description": "Cloudron tildeler 50 % af denne værdi som RAM og 50 % som swap.",
|
||||
"description": "Maksimal arbejdshastighed, som appen kan bruge",
|
||||
"error": "Kan ikke indstille memory limit, prøv mindre.",
|
||||
"resizeAction": "Ændre størrelse"
|
||||
}
|
||||
@@ -1492,11 +1531,12 @@
|
||||
"packageVersion": "Pakkeversion",
|
||||
"lastUpdated": "Sidst opdateret",
|
||||
"checkForUpdatesAction": "Tjek for opdateringer",
|
||||
"customAppUpdateInfo": "Opdateringer er ikke tilgængelige for brugerdefinerede apps",
|
||||
"updateAvailableAction": "Opdatering tilgængelig"
|
||||
"customAppUpdateInfo": "Automatisk opdatering er ikke tilgængelig for brugerdefinerede apps.",
|
||||
"updateAvailableAction": "Opdatering tilgængelig",
|
||||
"installedAt": "Installeret på"
|
||||
},
|
||||
"auto": {
|
||||
"description": "Cloudron kontrollerer jævnligt App Store for opdateringer. Hvis du deaktiverer automatiske opdateringer, skal du sørge for at anvende opdateringerne manuelt.",
|
||||
"description": "Cloudron tjekker med jævne mellemrum <a href=»{{ appStoreLink }}« target=»_blank«>App Store</a> for opdateringer.",
|
||||
"title": "Automatiske opdateringer",
|
||||
"enabled": "Automatiske opdateringer er i øjeblikket aktiveret.",
|
||||
"disabled": "Automatiske opdateringer er i øjeblikket deaktiveret.",
|
||||
@@ -1569,7 +1609,8 @@
|
||||
"openAction": "Åbn {{ app }}",
|
||||
"firstTimeTitle": "Første gang du bruger det",
|
||||
"firstTimeCollapseHeader": "Første gangs opsætningsvejledning",
|
||||
"customAppUpdateWarning": "Dette er en brugerdefineret app, som ikke er installeret fra App Store og ikke modtager opdateringer. Se <a target=\"_blank\" href=\"{{ docsLink }}\">Dokumentation</a> om, hvordan du opdaterer en brugerdefineret app."
|
||||
"customAppUpdateWarning": "Dette er en brugerdefineret app, som ikke er installeret fra App Store og ikke modtager opdateringer. Se <a target=\"_blank\" href=\"{{ docsLink }}\">Dokumentation</a> om, hvordan du opdaterer en brugerdefineret app.",
|
||||
"checklist": "Administrativ tjekliste"
|
||||
},
|
||||
"restoreDialog": {
|
||||
"warning": "Alle data, der er genereret mellem nu og den sidst kendte sikkerhedskopi, vil uigenkaldeligt gå tabt. Det anbefales at oprette en sikkerhedskopi af de aktuelle data, før du forsøger at gendanne dem.",
|
||||
@@ -1718,6 +1759,12 @@
|
||||
"title": "Redis-konfiguration",
|
||||
"enable": "Konfigurer appen til at bruge Redis",
|
||||
"disable": "Deaktiver Redis"
|
||||
},
|
||||
"infoTabTitle": "Info",
|
||||
"info": {
|
||||
"notes": {
|
||||
"title": "Administrative noter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passwordReset": {
|
||||
@@ -1821,7 +1868,11 @@
|
||||
"mountStatus": "Status for montering",
|
||||
"type": "Type",
|
||||
"localDirectory": "Lokal vejviser",
|
||||
"remountActionTooltip": "Genmonter"
|
||||
"remountActionTooltip": "Genmonter",
|
||||
"editVolumeDialog": {
|
||||
"title": "Rediger volumen {{ name }}"
|
||||
},
|
||||
"editActionTooltip": "Rediger volumen"
|
||||
},
|
||||
"newLoginEmail": {
|
||||
"topic": "Vi har bemærket et nyt login på din Cloudron-konto.",
|
||||
@@ -1859,7 +1910,8 @@
|
||||
"signInAction": "Log ind",
|
||||
"resetPasswordAction": "Nulstil adgangskode",
|
||||
"errorIncorrect2FAToken": "2FA-token er ugyldig",
|
||||
"errorInternal": "Intern fejl, prøv igen senere"
|
||||
"errorInternal": "Intern fejl, prøv igen senere",
|
||||
"loginWith": "Log ind med Cloudron"
|
||||
},
|
||||
"lang": {
|
||||
"en": "English",
|
||||
@@ -1874,7 +1926,8 @@
|
||||
"es": "Spansk",
|
||||
"ru": "Russisk",
|
||||
"pt": "Portugisisk",
|
||||
"da": "Dansk"
|
||||
"da": "Dansk",
|
||||
"id": "Indonesisk"
|
||||
},
|
||||
"supportConfig": {
|
||||
"emailNotVerified": "Du bedes først bekræfte e-mailen på cloudron.io-kontoen for at sikre, at vi kan kontakte dig."
|
||||
|
||||
@@ -167,13 +167,11 @@
|
||||
"updates": {
|
||||
"checkForUpdatesAction": "Auf Aktualisierungen überprüfen",
|
||||
"title": "Aktualisierungen",
|
||||
"currentSchedule": "Die Einstellungen für die automatische Aktualisierung für System und Anwendungen lautet:",
|
||||
"version": "Systemversion",
|
||||
"changeScheduleAction": "Zeitplan ändern",
|
||||
"stopUpdateAction": "Aktualisierung abbrechen",
|
||||
"updateAvailableAction": "Aktualisierung verfügbar",
|
||||
"showLogsAction": "Logfiles anzeigen",
|
||||
"autoUpdateDisabled": "Die automatische Aktualisierung des Systems und der Anwendungen ist <b>deaktiviert</b>."
|
||||
"showLogsAction": "Logfiles anzeigen"
|
||||
},
|
||||
"appstoreAccount": {
|
||||
"title": "Cloudron.io-Konto",
|
||||
|
||||
@@ -521,7 +521,7 @@
|
||||
"title": "Backups",
|
||||
"location": {
|
||||
"title": "Location",
|
||||
"description": "Cloudron makes a complete backup of your system at the configured location.",
|
||||
"description": "A complete backup of your system is saved to the storage location with the configured format.",
|
||||
"disabledList": "The following apps have automatic backups disabled:",
|
||||
"provider": "Provider",
|
||||
"location": "Location",
|
||||
@@ -532,7 +532,7 @@
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule and Retention",
|
||||
"description": "Cloudron makes a complete backup of your system based on this scheduled interval and keeps backups with the specified retention policy.",
|
||||
"description": "A complete backup of the system is created based on the specified Schedule in the <a href=\"/#/settings\">System Time Zone</a>. Old backups are removed based on the Retention Policy.",
|
||||
"schedule": "Schedule",
|
||||
"retentionPolicy": "Retention Policy",
|
||||
"configure": "Configure"
|
||||
@@ -788,7 +788,7 @@
|
||||
"title": "Network",
|
||||
"ip": {
|
||||
"title": "IPv4",
|
||||
"description": "Cloudron uses this IPv4 address to setup DNS A records.",
|
||||
"description": "This IPv4 address is used to set up DNS A records.",
|
||||
"provider": "Provider",
|
||||
"interface": "Network Interface Name",
|
||||
"configure": "Configure",
|
||||
@@ -821,7 +821,7 @@
|
||||
"ipv6": {
|
||||
"address": "IPv6 Address",
|
||||
"title": "IPv6",
|
||||
"description": "Cloudron uses this IPv6 address to setup DNS AAAA records.\n"
|
||||
"description": "This IPv6 address is used to set up DNS AAAA records."
|
||||
},
|
||||
"configureIpv6": {
|
||||
"title": "Configure IPv6 Provider"
|
||||
@@ -835,7 +835,7 @@
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
"description": "Cloudron services implement functionality such as databases, email and authentication.",
|
||||
"description": "Services implement functionality such as databases, email and authentication.",
|
||||
"service": "Service",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"memoryLimit": "Memory Limit",
|
||||
@@ -869,19 +869,20 @@
|
||||
"emailNotVerified": "Email not yet verified"
|
||||
},
|
||||
"timezone": {
|
||||
"title": "Time Zone",
|
||||
"description": "The current timezone setting is <b>{{ timeZone }}</b>.\nThis setting is used for scheduling backup and update tasks."
|
||||
"title": "System Time Zone",
|
||||
"description": "The current timezone setting is <b>{{ timeZone }}</b>. This setting is used for scheduling backup and update tasks. Timestamps in the UI are always displayed using the browser's timezone."
|
||||
},
|
||||
"updates": {
|
||||
"title": "Updates",
|
||||
"autoUpdateDisabled": "Automatic update for the platform and apps is <b>disabled</b>.",
|
||||
"currentSchedule": "The current automatic update schedule for platform and apps is",
|
||||
"version": "Platform version",
|
||||
"showLogsAction": "Show Logs",
|
||||
"changeScheduleAction": "Change Schedule",
|
||||
"checkForUpdatesAction": "Check for Updates",
|
||||
"updateAvailableAction": "Update Available",
|
||||
"stopUpdateAction": "Stop Update"
|
||||
"stopUpdateAction": "Stop Update",
|
||||
"disabled": "Disabled",
|
||||
"schedule": "Schedule",
|
||||
"description": "Platform and App Updates are automatically applied based on the Schedule in the <a href=\"/#/settings\">System Time Zone</a>."
|
||||
},
|
||||
"privateDockerRegistry": {
|
||||
"title": "Private Docker Registry",
|
||||
@@ -1016,7 +1017,7 @@
|
||||
"tooltipRemove": "Remove Domain",
|
||||
"renewCerts": {
|
||||
"title": "Renew certificates",
|
||||
"description": "Cloudron renews Let's Encrypt certificates automatically. Use this option to trigger a renewal immediately.",
|
||||
"description": "Let's Encrypt certificates are renewed automatically. Use this option to trigger a renewal immediately.",
|
||||
"renewAllAction": "Renew All Certs",
|
||||
"showLogsAction": "Show Logs"
|
||||
},
|
||||
@@ -1301,7 +1302,7 @@
|
||||
"outbound": {
|
||||
"tabTitle": "Outbound",
|
||||
"title": "Email Relay",
|
||||
"description": "Cloudron will use this mail server (Smart host) to send the outbound mails of apps installed under this domain.",
|
||||
"description": "This mail server (Smart host) will be used to send the outbound mails of apps installed under this domain.",
|
||||
"noopAdminDomainWarning": "Cloudron cannot send user invites, password reset and other notifications when email is disabled on the primary domain",
|
||||
"noopNonAdminDomainWarning": "Cloudron cannot provide email sending for apps hosted under this domain when email is disabled.",
|
||||
"mailRelay": {
|
||||
@@ -1627,7 +1628,7 @@
|
||||
},
|
||||
"auto": {
|
||||
"title": "Automatic Backups",
|
||||
"description": "Cloudron periodically creates a backup based on the <a href=\"{{ backupLink }}\">backup</a> settings.",
|
||||
"description": "Backups are periodically created based on the <a href=\"{{ backupLink }}\">Backup Schedule</a>.",
|
||||
"enabled": "Automatic Backups is currently enabled.",
|
||||
"disabled": "Automatic Backups is currently disabled.",
|
||||
"disableAction": "Disable Automatic Backups",
|
||||
@@ -1672,7 +1673,8 @@
|
||||
"openAction": "Open {{ app }}",
|
||||
"firstTimeTitle": "First Time Usage",
|
||||
"firstTimeCollapseHeader": "First time setup instructions",
|
||||
"customAppUpdateWarning": "This is a custom app and not installed from the App Store and will not receive updates. See the <a target=\"_blank\" href=\"{{ docsLink }}\">Documentation</a> on how to update a custom app."
|
||||
"customAppUpdateWarning": "This is a custom app and not installed from the App Store and will not receive updates. See the <a target=\"_blank\" href=\"{{ docsLink }}\">Documentation</a> on how to update a custom app.",
|
||||
"checklist": "Admin Checklist"
|
||||
},
|
||||
"uninstallDialog": {
|
||||
"title": "Uninstall {{ app }}",
|
||||
|
||||
@@ -871,8 +871,6 @@
|
||||
"changeScheduleAction": "Cambiar Programación",
|
||||
"showLogsAction": "Mostrar Registros",
|
||||
"version": "Versión de la Plataforma",
|
||||
"currentSchedule": "El programa actual de actualización automática para la plataforma y las aplicaciones es",
|
||||
"autoUpdateDisabled": "La actualización automática de la plataforma y las aplicaciones está <b> desactivada </b>.",
|
||||
"title": "Actualizaciones"
|
||||
},
|
||||
"language": {
|
||||
|
||||
@@ -791,8 +791,6 @@
|
||||
"changeScheduleAction": "Modifier la fréquence",
|
||||
"showLogsAction": "Afficher les journaux",
|
||||
"version": "Version de la plateforme",
|
||||
"currentSchedule": "La mise à jour automatique de la plateforme et des application a lieu",
|
||||
"autoUpdateDisabled": "La mise à jour automatique de la plateforme et des applications est <b>désactivée</b>.",
|
||||
"title": "Mises à jour"
|
||||
},
|
||||
"timezone": {
|
||||
|
||||
@@ -1151,8 +1151,6 @@
|
||||
"changeScheduleAction": "Cambia Pianificazione",
|
||||
"showLogsAction": "Visualizza Logs",
|
||||
"version": "Versione piattaforma",
|
||||
"currentSchedule": "L'attuale programma di aggiornamento automatico per piattaforma e app è",
|
||||
"autoUpdateDisabled": "L'aggiornamento automatico per la piattaforma e le app è <b>disabilitato</b>.",
|
||||
"title": "Aggiornamenti"
|
||||
},
|
||||
"timezone": {
|
||||
|
||||
@@ -1038,7 +1038,7 @@
|
||||
"packageVersion": "Pakketversie",
|
||||
"lastUpdated": "Laatst geüpdatet",
|
||||
"checkForUpdatesAction": "Controleer op updates",
|
||||
"customAppUpdateInfo": "Er zijn geen updates beschikbaar voor deze maatwerk app",
|
||||
"customAppUpdateInfo": "Auto-update is niet beschikbaar voor maatwerk apps.",
|
||||
"updateAvailableAction": "Update beschikbaar",
|
||||
"repository": "Pakket Opslagplaats",
|
||||
"installedAt": "Geïnstalleerd op"
|
||||
@@ -1047,9 +1047,9 @@
|
||||
"title": "Automatische updates",
|
||||
"enabled": "Automatische updates zijn momenteel ingeschakeld.",
|
||||
"disabled": "Automatische updates zijn momenteel uitgeschakeld.",
|
||||
"disableAction": "Uitschakelen",
|
||||
"enableAction": "Inschakelen",
|
||||
"description": "Cloudron controleert de App Store periodiek op updates. Als je dit uitschakelt zorg er dan voor dat je updates handmatig installeert."
|
||||
"disableAction": "Auto-update uitschakelen",
|
||||
"enableAction": "Auto-update inschakelen",
|
||||
"description": "Cloudron controleert periodiek de <a href=\"{{ appStoreLink }}\" target=\"_blank\">App Store</a> op updates."
|
||||
},
|
||||
"noUpdates": "Geen nieuwe updates beschikbaar"
|
||||
},
|
||||
@@ -1117,7 +1117,8 @@
|
||||
"customAppUpdateWarning": "Dit is een aangepaste app en niet geïnstalleerd vanuit de App Store, het krijgt hierdoor geen updates. Lees de <a target=\"_blank\" href=\"{{ docsLink }}\">documentatie</a> over hoe je een aangepaste app kunt updaten.",
|
||||
"appDocsUrl": "Bekijk de <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} documentatie</a> voor informatie en tips over deze app. Indien je meer hulp nodig hebt ga dan naar Cloudron's <a target=\"_blank\" href=\"{{ forumUrl }}\">{{ title }} forum</a>.",
|
||||
"sso": "Deze app is ingesteld voor authenticatie via het Cloudron gebuikersadresboek. Cloudron gebruikers kunnen inloggen en het direct gebruiken.",
|
||||
"ssoEmail": "Deze app is zodanig ingesteld dat alle gebruikers met een e-mailbox op deze Cloudron toegang hebben. Log in met je e-mailadres en wachtwoord voor toegang tot die e-mailbox."
|
||||
"ssoEmail": "Deze app is zodanig ingesteld dat alle gebruikers met een e-mailbox op deze Cloudron toegang hebben. Log in met je e-mailadres en wachtwoord voor toegang tot die e-mailbox.",
|
||||
"checklist": "Admin Controlelijst"
|
||||
},
|
||||
"uninstallDialog": {
|
||||
"uninstallAction": "De-installeer",
|
||||
@@ -1315,12 +1316,10 @@
|
||||
},
|
||||
"timezone": {
|
||||
"title": "Tijdzone",
|
||||
"description": "De huidige tijdzone instelling is <b>{{ timeZone }}</b>.\nDeze instelling wordt gebruikt voor backup planning en update taken."
|
||||
"description": "De huidige tijdzone instelling is <b>{{ timeZone }}</b>. Deze instelling wordt gebruikt voor backup planning en update taken. Tijdseenheden in de gebruikersschermen zijn op basis van de brower’s tijdzone."
|
||||
},
|
||||
"updates": {
|
||||
"title": "Updates",
|
||||
"autoUpdateDisabled": "Automatische update voor het platform en apps is <b>uitgeschakeld</b>.",
|
||||
"currentSchedule": "De huidige automatische update planning voor het platform en de apps is",
|
||||
"showLogsAction": "Toon logbestanden",
|
||||
"changeScheduleAction": "Planning aanpassen",
|
||||
"checkForUpdatesAction": "Controleer op updates",
|
||||
@@ -1492,7 +1491,8 @@
|
||||
"upload": {
|
||||
"title": "Uploaden bestand naar {{ name }}"
|
||||
},
|
||||
"uploadToTmp": "Upload naar /tmp"
|
||||
"uploadToTmp": "Upload naar /tmp",
|
||||
"uploadTo": "Upload naar {{ path }}"
|
||||
},
|
||||
"filemanager": {
|
||||
"title": "Bestandsbeheer",
|
||||
|
||||
@@ -1228,9 +1228,7 @@
|
||||
"checkForUpdatesAction": "Проверить обновления",
|
||||
"updateAvailableAction": "Обновление доступно",
|
||||
"version": "Версия платформы",
|
||||
"stopUpdateAction": "Остановить обновление",
|
||||
"autoUpdateDisabled": "Автоматические обновления для платформы и приложений <b>выключены</b>.",
|
||||
"currentSchedule": "Текущее расписание автоматических обновлений для платформы и приложений"
|
||||
"stopUpdateAction": "Остановить обновление"
|
||||
},
|
||||
"privateDockerRegistry": {
|
||||
"title": "Частный реестр Docker",
|
||||
|
||||
@@ -22,14 +22,18 @@
|
||||
"auth": {
|
||||
"email": "Đăng nhập bằng email",
|
||||
"sso": "Đăng nhập với tên & mật khẩu trên Cloudron",
|
||||
"nosso": "Đăng nhập vào tài khoản riêng"
|
||||
"nosso": "Đăng nhập bằng tài khoản riêng",
|
||||
"openid": "Đăng nhập bằng Cloudron OpenID"
|
||||
},
|
||||
"addAppAction": "Thêm App",
|
||||
"addApplinkAction": "Thêm đường link App",
|
||||
"addApplinkAction": "Thêm link App",
|
||||
"filter": {
|
||||
"clearAll": "Xoá tất cả"
|
||||
},
|
||||
"addAppproxyAction": "Thêm proxy cho app"
|
||||
"addAppproxyAction": "Thêm proxy cho app",
|
||||
"apps": {
|
||||
"count": "Tổng số app: {{ count }}"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
"logout": "Thoát",
|
||||
@@ -80,7 +84,8 @@
|
||||
"justNow": "mới đây",
|
||||
"yeserday": "Hôm qua",
|
||||
"minutesAgo": "{{ m }} phút trước",
|
||||
"hoursAgo": "{{ h }} tiếng trước"
|
||||
"hoursAgo": "{{ h }} tiếng trước",
|
||||
"never": "Chưa lần nào"
|
||||
},
|
||||
"statusEnabled": "Đã bật",
|
||||
"statusDisabled": "Đã tắt",
|
||||
@@ -107,7 +112,7 @@
|
||||
"finance": "Tài chính",
|
||||
"git": "Chạy code",
|
||||
"email": "Email",
|
||||
"game": "Game",
|
||||
"game": "Trò chơi",
|
||||
"hosting": "Chạy web",
|
||||
"media": "Hình ảnh",
|
||||
"learning": "Học tập",
|
||||
@@ -130,7 +135,7 @@
|
||||
"manualWarning": "Thêm A record cho <b>{{ nơi cài đặt }}</b> vào địa chỉ IP công cộng của Cloudron này",
|
||||
"userManagement": "Quản lý người dùng",
|
||||
"userManagementMailbox": "Tất cả người dùng với hộp thư trên Cloudron này có quyền truy cập app.",
|
||||
"userManagementLeaveToApp": "Để phần quản lý người dùng cho app",
|
||||
"userManagementLeaveToApp": "Để app quản lý người dùng",
|
||||
"userManagementAllUsers": "Cho phép tất cả người dùng trên Cloudron truy cập",
|
||||
"errorUserManagementSelectAtLeastOne": "Chọn ít nhất một người dùng hay nhóm",
|
||||
"users": "Người dùng",
|
||||
@@ -138,10 +143,10 @@
|
||||
"lowOnResources": "Cloudron này đang chạy gần hết bộ nhớ.",
|
||||
"pleaseUpgradeServer": "Hãy nâng cấp server có bộ nhớ nhiều hơn. Hoặc, xoá những app không dùng đến để có thêm chỗ trống.",
|
||||
"setupSubscriptionAction": "Cài đặt gói đăng ký",
|
||||
"installAnywayAction": "Vẫn tải về luôn",
|
||||
"installAnywayAction": "Vẫn tải về",
|
||||
"installAction": "Tải về",
|
||||
"subscriptionRequired": "Để cài đặt thêm app, hãy đăng ký gói trả phí.",
|
||||
"userManagementNone": "App này có phần quản lý người dùng riêng. Phần cài đặt này điều chỉnh app có hiển thị hay không trên bảng dashboard của người dùng.",
|
||||
"userManagementNone": "App này có phần quản lý người dùng riêng. Cài đặt này điều chỉnh app có hiển thị hay không trên bảng dashboard của người dùng.",
|
||||
"userManagementSelectUsers": "Chỉ cho phép người dùng và nhóm sau",
|
||||
"configuredForCloudronEmail": "App này đã được cấu hình sẵn để sử dụng với <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Cloudron Email</a>.",
|
||||
"doInstallAction": "Tải về {{ dnsOverwrite ? 'and overwrite DNS' : '' }}",
|
||||
@@ -167,7 +172,10 @@
|
||||
"switchToLoginAction": "Đã có tài khoản rồi? Đăng nhập",
|
||||
"switchToSignUpAction": "Chưa có tài khoản? Hãy đăng ký nhé",
|
||||
"description": "Tài khoản này được dùng để truy cập Cửa hàng App và quản lý gói đăng ký của bạn",
|
||||
"licenseCheckbox": "Tôi đồng ý <a href=\"{{ licenseLink }}\" target=\"_blank\">bản quyền</a> của Cloudron"
|
||||
"licenseCheckbox": "Tôi đồng ý <a href=\"{{ licenseLink }}\" target=\"_blank\">bản quyền</a> của Cloudron",
|
||||
"setupWithTokenAction": "Cài đặt",
|
||||
"titleToken": "Đăng ký với Mã cài đặt",
|
||||
"setupToken": "Cài đặt Mã"
|
||||
},
|
||||
"searchPlaceholder": "Tìm kiếm app thay thế cho Github, Dropbox, Slack, Trello, …",
|
||||
"appMissing": "Thiếu app nào đó? Hãy nhắn cho chúng tôi.",
|
||||
@@ -205,7 +213,9 @@
|
||||
"username": "Tên đăng nhập",
|
||||
"fullName": "Họ tên",
|
||||
"fallbackEmailPlaceholder": "Không bắt buộc. Nếu không được xác định, email chính sẽ được sử dụng",
|
||||
"displayNamePlaceholder": "Không bắt buộc. Nếu để trống, người dùng có thể tự cài đặt trong lúc đăng ký"
|
||||
"displayNamePlaceholder": "Không bắt buộc. Nếu để trống, người dùng có thể tự cài đặt trong lúc đăng ký",
|
||||
"external2FA": "Nguồn xác thực ngoài đang quản lý cài đặt Mã xác minh 2 Bước",
|
||||
"ldapGroups": "Nhóm LDAP"
|
||||
},
|
||||
"addUserDialog": {
|
||||
"addUserAction": "Thêm người dùng",
|
||||
@@ -223,7 +233,7 @@
|
||||
"configureAction": "Cấu hình",
|
||||
"syncAction": "Đồng bộ",
|
||||
"showLogsAction": "Hiển thị log",
|
||||
"autocreateUsersOnLogin": "Tự động tạo tài khoản người dùng khi họ đăng nhập vào Cloudron",
|
||||
"autocreateUsersOnLogin": "Tự động tạo người dùng khi họ đăng ký",
|
||||
"auth": "Xác minh",
|
||||
"groupnameField": "Vùng tên nhóm",
|
||||
"groupFilter": "Lọc nhóm",
|
||||
@@ -237,10 +247,11 @@
|
||||
"provider": "Nhà cung cấp",
|
||||
"noopInfo": "Xác thực LDAP chưa được thiết lập.",
|
||||
"subscriptionRequiredAction": "Cài đặt gói đăng ký ngay",
|
||||
"description": "Cloudron sẽ đồng bộ người dùng và nhóm từ server LDAP hay ActiveDirectory bên ngoài. Xác minh mật khẩu cho người dùng được dựa trên server ngoài. Việc đồng bộ hoá không được chạy tự động mà cần được khởi động bằng tay.",
|
||||
"description": "Cài đặt này đồng bộ và xác thực người dùng và nhóm từ một server LDAP hay ActiveDirectory bên ngoài. Sự đồng bộ hóa này được chạy theo chu kỳ nhưng cũng có thể được khởi động bằng tay.",
|
||||
"title": "Kết nối thư mục ngoài",
|
||||
"providerOther": "Khác",
|
||||
"providerDisabled": "Đã tắt"
|
||||
"providerDisabled": "Đã tắt",
|
||||
"disableWarning": "Nguồn mã xác minh cho tất cả người dùng hiện hữu sẽ được cài đặt lại dựa trên cơ sở dữ liệu mật khẩu nội bộ trên server."
|
||||
},
|
||||
"users": {
|
||||
"inactiveTooltip": "Người dùng không hoạt động",
|
||||
@@ -250,12 +261,12 @@
|
||||
"notActivatedYetTooltip": "Người dùng chưa được kích hoạt",
|
||||
"externalLdapTooltip": "Từ thư mục LDAP ngoài",
|
||||
"usermanagerTooltip": "Người dùng này có thể quản lý nhóm và những người dùng khác",
|
||||
"adminTooltip": "Người dùng này có vai trò admin",
|
||||
"superadminTooltip": "Người dùng này có vai trò superadmin",
|
||||
"adminTooltip": "Người dùng này là admin",
|
||||
"superadminTooltip": "Người dùng này là superadmin",
|
||||
"empty": "Không tìm thấy người dùng",
|
||||
"groups": "Nhóm",
|
||||
"user": "Người dùng",
|
||||
"transferOwnershipTooltip": "Chuyển đổi quyền sở hữu",
|
||||
"transferOwnershipTooltip": "Chuyển nhượng quyền sở hữu",
|
||||
"invitationTooltip": "Mời Người dùng",
|
||||
"setGhostTooltip": "Nhập vai",
|
||||
"count": "Tổng ng dùng: {{ count }}",
|
||||
@@ -334,7 +345,7 @@
|
||||
"enabled": "Đã bật",
|
||||
"title": "Máy chủ chỉ mục",
|
||||
"ipRestriction": {
|
||||
"description": "Máy chủ chỉ mục có thể được giới hạn cho những địa chỉ IP hoặc khoảng vùng cụ thể.",
|
||||
"description": "Giới hạn quyền truy cập máy chủ chỉ mục cho những địa chỉ IP hoặc khoảng vùng cụ thể. Những dòng bắt đầu bằng dấu <code>#</code> được xem như ghi chú thêm.",
|
||||
"placeholder": "Viết xuống dòng những địa chỉ IP hoặc Subnet",
|
||||
"label": "Giới hạn quyền truy cập"
|
||||
},
|
||||
@@ -342,7 +353,8 @@
|
||||
"label": "Mật khẩu bind",
|
||||
"description": "Tất cả những yêu cầu LDAP cần phải được xác minh với mã bí mật này và tên người dùng user DN <i>{{ userDN }}</i>",
|
||||
"url": "URL máy chủ"
|
||||
}
|
||||
},
|
||||
"cloudflarePortWarning": "Cần tắt proxy Cloudflare cho tên miền dashboard để truy cập LDAP server"
|
||||
},
|
||||
"userImportDialog": {
|
||||
"success": "{{ count }} người dùng đã được nhập vào.",
|
||||
@@ -478,7 +490,10 @@
|
||||
"changeEmail": {
|
||||
"title": "Thay đổi email chính",
|
||||
"errorEmailInvalid": "Email không hợp lệ",
|
||||
"errorEmailRequired": "Bạn cần nhập một email hợp lệ"
|
||||
"errorEmailRequired": "Bạn cần nhập một email hợp lệ",
|
||||
"email": "Thêm địa chỉ mail mới",
|
||||
"password": "Mật khẩu để xác nhận",
|
||||
"errorWrongPassword": "Sai mật khẩu"
|
||||
},
|
||||
"disable2FAAction": "Tắt xác minh hai bước",
|
||||
"changeFallbackEmail": {
|
||||
@@ -499,7 +514,8 @@
|
||||
"passwordResetNotification": {
|
||||
"title": "Đã đặt lại mật khẩu thành công",
|
||||
"body": "Email đã được gửi đến {{ email }}"
|
||||
}
|
||||
},
|
||||
"enable2FANotAvailable": "Không cài được cho người dùng từ nguồn xác minh ngoài"
|
||||
},
|
||||
"backups": {
|
||||
"location": {
|
||||
@@ -617,7 +633,7 @@
|
||||
},
|
||||
"check": {
|
||||
"noop": "Tính năng sao lưu Cloudron đã tắt. Hãy chắc rằng server được sao lưu bằng một biện pháp khác. Xem thông tin thêm tại https://docs.cloudron.io/backups/#storage-providers.",
|
||||
"sameDisk": "Các bản sao lưu Cloudron đang ở trên cùng ổ đĩa với server chạy Cloudron. Việc này sẽ nguy hiểm và có thể dẫn đến mất dữ liệu nếu ổ đĩa bị trục trặc. Xem cách sao lưu tại ổ đĩa ngoài tại https://docs.cloudron.io/backups/#storage-providers."
|
||||
"sameDisk": "Các bản sao lưu Cloudron hiện đang ở trên cùng ổ đĩa với server chạy Cloudron. Nếu ổ đĩa chứa đầy các bản sao lưu, Cloudron sẽ không hoạt động được. Sự cố trục trặc ổ đĩa cũng có thể làm mất dữ liệu hoàn toàn. Xem cách sao lưu tại ổ đĩa ngoài tại https://docs.cloudron.io/backups/#storage-providers."
|
||||
},
|
||||
"backupEdit": {
|
||||
"preserved": {
|
||||
@@ -625,7 +641,8 @@
|
||||
"description": "Vẫn giữ bản sao lưu mặc kệ chính sách lưu giữ được định thế nào"
|
||||
},
|
||||
"title": "Chỉnh sửa Bản sao lưu",
|
||||
"label": "Nhãn"
|
||||
"label": "Nhãn",
|
||||
"remotePath": "Đường dẫn"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@@ -637,7 +654,8 @@
|
||||
"errorIncorrectCredentials": "Không đúng tên đăng nhập hoặc mật khẩu",
|
||||
"loginTo": "Đăng nhập vào",
|
||||
"errorIncorrect2FAToken": "Mã bảo mật 2 Bước không đúng",
|
||||
"errorInternal": "Lỗi nội bộ hệ thống, vui lòng thử lại sau"
|
||||
"errorInternal": "Lỗi nội bộ hệ thống, vui lòng thử lại sau",
|
||||
"loginWith": "Đăng nhập bằng Cloudron"
|
||||
},
|
||||
"setupAccount": {
|
||||
"username": "Tên đăng nhập",
|
||||
@@ -670,7 +688,7 @@
|
||||
"enableAction": "Bật",
|
||||
"setupDnsInfo": "Sử dụng lựa chọn này để cài đặt những bản ghi có liên quan đến email. Để trống lựa chọn này sẽ hữu ích cho việc tạo ra các hộp thư và <a href=\"{{ importEmailDocsLink }}\">nhập dữ liệu các mail đã có sẵn</a> trước khi đưa vào sử dụng.",
|
||||
"setupDnsCheckbox": "Cài đặt các bản ghi DNS ngay",
|
||||
"cloudflareInfo": "Tên miền <code>{{ adminDomain }}</code> được quản lý bởi Cloudflare. Xin chắc rằng proxy qua Cloudflare đã được tắt cho <code>{{ mailFqdn }}</code> và được chỉnh về chế độ<code>DNS only</code>. Việc này là cần thiết vì Cloudflare không proxy được email.",
|
||||
"cloudflareInfo": "Tên miền cho mail server <code>{{ adminDomain }}</code> được quản lý bởi Cloudflare. Hãy nhớ tắt proxy qua Cloudflare cho <code>{{ mailFqdn }}</code> và chỉnh về chế độ <code>DNS only</code>. Cần làm vậy vì Cloudflare không proxy được email.",
|
||||
"noProviderInfo": "Chưa cài đặt nhà cung cấp DNS. Những bản ghi DNS trong phần Trạng thái cần được cài đặt thủ công.",
|
||||
"description": "Lựa chọn này sẽ cấu hình Cloudron để nhận mail cho <b>{{ domain }}</b>. Xem hướng dẫn để mở <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">những cổng cần thiết</a> cho Email Cloudron.",
|
||||
"title": "Bật chế độ email cho {{ domain }}?"
|
||||
@@ -856,7 +874,7 @@
|
||||
"network": {
|
||||
"configureIp": {
|
||||
"providerGenericDescription": "Địa chỉ IP công cộng của server này sẽ được tự động dò tìm ra.",
|
||||
"title": "Cấu hình nhà cung cấp IP"
|
||||
"title": "Cấu hình nhà cung cấp IPv4"
|
||||
},
|
||||
"dyndns": {
|
||||
"description": "Bật lựa chọn này để đồng bộ các bản ghi DNS với một địa chỉ IP thường xuyên thay đổi. Việc này hữu ích khi Cloudron chạy trên hệ thống mạng với địa chỉ IP hay thay đổi như kết nối mạng ở nhà.",
|
||||
@@ -879,8 +897,8 @@
|
||||
"configure": "Cấu hình",
|
||||
"interface": "Tên giao diện mạng",
|
||||
"provider": "Nhà cung cấp",
|
||||
"description": "Cloudron dùng địa chỉ IP này để cài đặt các bản ghi DNS.",
|
||||
"title": "Địa chỉ IP",
|
||||
"description": "Cloudron dùng địa chỉ IPv4 này để cài đặt các bản ghi A của DNS.",
|
||||
"title": "IPv4",
|
||||
"address": "Địa chỉ IP"
|
||||
},
|
||||
"title": "Mạng",
|
||||
@@ -981,7 +999,8 @@
|
||||
"info": "Các cài đặt này áp dụng cho tất cả các tên miền.",
|
||||
"title": "Cài đặt",
|
||||
"acl": "Danh sách quản lý truy cập mail",
|
||||
"aclOverview": "{{ dnsblZonesCount }} vùng DNSBL"
|
||||
"aclOverview": "{{ dnsblZonesCount }} vùng DNSBL",
|
||||
"virtualAllMail": "Thư mực \"Tất cả Thư\""
|
||||
},
|
||||
"domains": {
|
||||
"testEmailTooltip": "Gửi mail thử",
|
||||
@@ -1018,6 +1037,10 @@
|
||||
},
|
||||
"action": {
|
||||
"queue": "Cho vào hàng chờ gửi sau"
|
||||
},
|
||||
"changeVirtualAllMailDialog": {
|
||||
"description": "Thư mục \"Tất cả Thư\" là một thư mục chứa tất cả thư trong hộp thư của bạn. Thư mục này hữu dụng cho những mail client mà không hỗ trợ chức năng tìm kiếm thư mục xoay vòng.",
|
||||
"title": "Thư mực \"Tất cả Thư\""
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
@@ -1032,7 +1055,9 @@
|
||||
},
|
||||
"logo": "Logo",
|
||||
"cloudronName": "Tên cho Cloudron",
|
||||
"title": "Thương hiệu"
|
||||
"title": "Thương hiệu",
|
||||
"backgroundImage": "Hình nền trang đăng nhập",
|
||||
"clearBackgroundImage": "Xoá"
|
||||
},
|
||||
"eventlog": {
|
||||
"time": "Thời gian",
|
||||
@@ -1064,7 +1089,19 @@
|
||||
"uninstalledApp": "App đã xoá",
|
||||
"diskSpeed": "Tốc độ: {{ speed }} MB/s"
|
||||
},
|
||||
"title": "Hệ thống"
|
||||
"title": "Hệ thống",
|
||||
"info": {
|
||||
"activationTime": "Ngày tạo Cloudron",
|
||||
"platformVersion": "Phiên bản hệ thống",
|
||||
"title": "Thông tin",
|
||||
"vendor": "Nhà cung cấp",
|
||||
"product": "Sản phẩm",
|
||||
"memory": "Bộ nhớ",
|
||||
"uptime": "Thời gian online"
|
||||
},
|
||||
"graphs": {
|
||||
"title": "Biểu đồ"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
"remoteSupport": {
|
||||
@@ -1093,9 +1130,14 @@
|
||||
"subscriptionRequired": "Phiếu hỗ trợ chỉ có trong những gói trả phí.",
|
||||
"title": "Phiếu hỗ trợ",
|
||||
"emailNotVerified": "Email tài khoản cloudron.io của bạn {{ email }} chưa được xác minh. Xin hãy xác minh mail trước để tạo phiếu hỗ trợ.",
|
||||
"emailVerifyAction": "Xác minh ngay"
|
||||
"emailVerifyAction": "Xác minh ngay",
|
||||
"typeBilling": "Vấn đề Hóa đơn"
|
||||
},
|
||||
"title": "Hỗ trợ"
|
||||
"title": "Hỗ trợ",
|
||||
"help": {
|
||||
"description": "Xin dùng những nguồn lực sau để được trợ giúp và hỗ trợ\n* [Diễn dàn Cloudron]({{ forumLink }}) - Vui lòng vào Mục Hỗ trợ & App cụ thể để đặt câu hỏi.\n* [HDSD & Kho kiến thức Cloudron]({{ docsLink }})\n* [Đóng gói App tùy chỉnh & API]({{ packagingLink }})\n",
|
||||
"title": "Hỗ trợ"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"registryConfig": {
|
||||
@@ -1148,17 +1190,15 @@
|
||||
"changeScheduleAction": "Thay đổi lịch cập nhật",
|
||||
"showLogsAction": "Hiển thị log",
|
||||
"version": "Phiên bản hệ thống",
|
||||
"currentSchedule": "Lịch cập nhật tự động hiện tại cho hệ thống và các app là",
|
||||
"autoUpdateDisabled": "Cập nhật tự động cho hệ thống và các app <b>đã tắt</b>.",
|
||||
"title": "Cập nhật"
|
||||
},
|
||||
"timezone": {
|
||||
"description": "Múi giờ hiện tại là ở <b>{{ timeZone }}</b>.\nMúi giờ này được dùng cho việc lên lịch sao lưu và cập nhật hệ thống.",
|
||||
"description": "Múi giờ hiện tại là ở <b>{{ timeZone }}</b>. Cài đặt này được dùng cho tác vụ sao lưu và cập nhật. Dấu thời gian hiện ở giao diện được hiển thị theo múi giờ của trình duyệt hiện dùng.",
|
||||
"title": "Múi giờ"
|
||||
},
|
||||
"appstoreAccount": {
|
||||
"subscriptionReactivateAction": "Kích hoạt lại gói đăng ký",
|
||||
"subscriptionChangeAction": "Thay đổi gói đăng ký",
|
||||
"subscriptionChangeAction": "Quản lý gói đăng ký",
|
||||
"subscriptionSetupAction": "Nâng cấp Gói Cao cấp",
|
||||
"subscriptionEndsAt": "Đã huỷ đăng ký và kết thúc vào",
|
||||
"cloudronId": "Mã Cloudron ID",
|
||||
@@ -1264,7 +1304,8 @@
|
||||
"renameDialog": {
|
||||
"rename": "Đổi tên",
|
||||
"newName": "Tên mới",
|
||||
"title": "Đổi tên {{ fileName }}"
|
||||
"title": "Đổi tên {{ fileName }}",
|
||||
"reallyOverwrite": "Trùng tên tập tin hiện có. Ghi đè lên tập tin cũ?"
|
||||
},
|
||||
"newFileDialog": {
|
||||
"create": "Tạo",
|
||||
@@ -1315,7 +1356,8 @@
|
||||
"filePath": "Đường chỉ đến tập tin hay thư mục",
|
||||
"title": "Tải xuống từ {{ name }}"
|
||||
},
|
||||
"title": "Màn hình terminal"
|
||||
"title": "Màn hình terminal",
|
||||
"uploadTo": "Tải lên {{ path }}"
|
||||
},
|
||||
"logs": {
|
||||
"download": "Tải xuống tất cả log",
|
||||
@@ -1384,7 +1426,13 @@
|
||||
"cloudflareDefaultProxyStatus": "Bật tính năng proxy cho những bản ghi DNS mới",
|
||||
"porkbunSecretapikey": "Mã bí mật API",
|
||||
"bunnyAccessKey": "Mã truy cập Bunny",
|
||||
"porkbunApikey": "Key API"
|
||||
"porkbunApikey": "Key API",
|
||||
"deSecToken": "Mã deSEC",
|
||||
"dnsimpleAccessToken": "Mã truy cập",
|
||||
"ovhAppSecret": "Mã bí mật App",
|
||||
"ovhEndpoint": "Điểm Endpoint",
|
||||
"ovhConsumerKey": "Mã Khách hàng",
|
||||
"ovhAppKey": "Mã App"
|
||||
},
|
||||
"subscriptionRequired": {
|
||||
"description": "Để thêm tên miền, hãy đăng ký gói trả phí.",
|
||||
@@ -1432,13 +1480,14 @@
|
||||
"firstTimeCollapseHeader": "Hướng dẫn cho lần cài đặt đầu tiên",
|
||||
"openAction": "Mở {{ app }}",
|
||||
"postInstallConfirmCheckbox": "Đã xem hướng dẫn",
|
||||
"appDocsUrl": "Xin xem phần <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} hướng dẫn</a> để xem những thông tin hữu ích và chủ đề thường gặp của app này. Nếu bạn cần hỗ trợ thêm, hãy ghé xem trong<a target=\"_blank\" href=\"{{ forumUrl }}\"> diễn đàn {{ title }}</a>."
|
||||
"appDocsUrl": "Xin xem phần <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} hướng dẫn</a> để xem những thông tin hữu ích và chủ đề thường gặp của app này. Nếu bạn cần hỗ trợ thêm, hãy ghé xem trong<a target=\"_blank\" href=\"{{ forumUrl }}\"> diễn đàn {{ title }}</a>.",
|
||||
"checklist": "Danh sách kiểm tra cho Admin"
|
||||
},
|
||||
"uninstall": {
|
||||
"uninstall": {
|
||||
"uninstallAction": "Xoá",
|
||||
"backupWarning": "Các bản sao lưu app sẽ không được xoá ngay mà sẽ dựa vào lịch trình sao lưu được định sẵn. Bạn có thể hồi sinh app từ một bản sao lưu hiện có bằng những <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">hướng dẫn sau đây</a>.",
|
||||
"description": "Lựa chọn này sẽ gỡ cài đặt app ngay lập tức và xoá hết tất cả những dữ liệu liên quan. Trang web sẽ không còn truy cập được sau đó.",
|
||||
"description": "Việc này sẽ xóa app ngay lập tức và tất cả dữ liệu. Trang sẽ không còn truy cập được sau khi xóa.",
|
||||
"title": "Xoá"
|
||||
},
|
||||
"startStop": {
|
||||
@@ -1491,23 +1540,24 @@
|
||||
},
|
||||
"updates": {
|
||||
"auto": {
|
||||
"enableAction": "Bật chế độ cập nhật tự động",
|
||||
"disableAction": "Tắt chế độ cập nhật tự động",
|
||||
"enableAction": "Bật cập nhật tự động",
|
||||
"disableAction": "Tắt cập nhật tự động",
|
||||
"disabled": "Cập nhật tự động hiện đang tắt.",
|
||||
"enabled": "Cập nhật tự dộng đang được mở.",
|
||||
"description": "Cloudron định kỳ kiểm tra Cửa hàng app cho các phiên bản cập nhật mới. Nếu bạn tắt chế độ cập nhật tự động, xin chắc rằng bạn cài đặt thủ công các cập nhật phiên bản mới.",
|
||||
"description": "Cloudron định kỳ kiểm tra <a href=\"{{ appStoreLink }}\" target=\"_blank\">Cửa hàng App </a> cho phiên bản app mới.",
|
||||
"title": "Cập nhật tự động"
|
||||
},
|
||||
"info": {
|
||||
"updateAvailableAction": "Có phiên bản cập nhật mới",
|
||||
"customAppUpdateInfo": "Phiên bản mới không có sẵn cho các app tuỳ chỉnh",
|
||||
"customAppUpdateInfo": "Tự động cập nhật không có sẵn cho các app tùy chỉnh.",
|
||||
"checkForUpdatesAction": "Kiểm tra cập nhật",
|
||||
"lastUpdated": "Lần cuối cập nhật",
|
||||
"packageVersion": "Phiên bản đóng gói",
|
||||
"appId": "ID của app",
|
||||
"description": "Tên app và phiên bản",
|
||||
"title": "Thông tin app",
|
||||
"repository": "Repo của bản đống gói"
|
||||
"repository": "Repo của bản đống gói",
|
||||
"installedAt": "Được cài lúc"
|
||||
},
|
||||
"noUpdates": "Không có phiên bản mới"
|
||||
},
|
||||
@@ -1586,14 +1636,14 @@
|
||||
},
|
||||
"resources": {
|
||||
"cpu": {
|
||||
"description": "Phần trăm thời gian CPU dành cho app khi hệ thống đang chịu tải nặng.",
|
||||
"title": "Chia phần trong CPU",
|
||||
"setAction": "Cài đặt"
|
||||
"description": "Phần trăm CPU tối đa app có thể dùng",
|
||||
"title": "Giới hạn CPU",
|
||||
"setAction": "Nâng lên"
|
||||
},
|
||||
"memory": {
|
||||
"resizeAction": "Chỉnh lại",
|
||||
"error": "Hệ thống không chỉnh được giới hạn bộ nhớ này, hãy thử một giá trị thấp hơn.",
|
||||
"description": "Cloudron dành 50% giá trị này cho RAM và 50% còn lại cho swap.",
|
||||
"description": "Bộ nhớ tối đa app có thể dùng",
|
||||
"title": "Giới hạn bộ nhớ"
|
||||
}
|
||||
},
|
||||
@@ -1747,7 +1797,8 @@
|
||||
},
|
||||
"redis": {
|
||||
"title": "Thiết lập Redis",
|
||||
"enable": "Thiết lập app sử dụng Redis"
|
||||
"enable": "Thiết lập app sử dụng Redis",
|
||||
"disable": "Tắt Redis"
|
||||
},
|
||||
"addApplinkDialog": {
|
||||
"title": "Thêm link app bên ngoài"
|
||||
@@ -1761,6 +1812,12 @@
|
||||
"upstreamUri": "Đường dẫn bên ngoài",
|
||||
"label": "Nhãn",
|
||||
"clearIconAction": "Xoá biểu tượng"
|
||||
},
|
||||
"infoTabTitle": "Thông tin",
|
||||
"info": {
|
||||
"notes": {
|
||||
"title": "Ghi chú của Admin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"volumes": {
|
||||
@@ -1795,10 +1852,14 @@
|
||||
},
|
||||
"mountStatus": "Trạng thái mount",
|
||||
"type": "Dạng",
|
||||
"tooltipEdit": "Chỉnh sửa Volume",
|
||||
"tooltipEdit": "Chỉnh Volume",
|
||||
"localDirectory": "Thư mục trên máy",
|
||||
"remountActionTooltip": "Mount Volume lại",
|
||||
"mountType": "Dạng mount"
|
||||
"mountType": "Dạng mount",
|
||||
"editVolumeDialog": {
|
||||
"title": "Chỉnh volume {{ name }}"
|
||||
},
|
||||
"editActionTooltip": "Chỉnh Volume"
|
||||
},
|
||||
"welcomeEmail": {
|
||||
"inviteLinkAction": "Bắt đầu tạo tải khoản",
|
||||
@@ -1822,7 +1883,8 @@
|
||||
"es": "Tiếng Tây Ban Nha",
|
||||
"ru": "Tiếng Nga",
|
||||
"da": "Tiếng Đan Mạch",
|
||||
"pt": "Tiếng Bồ Đào Nha"
|
||||
"pt": "Tiếng Bồ Đào Nha",
|
||||
"id": "Tiếng Indonesia"
|
||||
},
|
||||
"passwordResetEmail": {
|
||||
"subject": "[<%= cloudron %>] Đặt lại mật khẩu",
|
||||
@@ -1859,7 +1921,7 @@
|
||||
"topic": "Chúng tôi nhận thấy có một đăng nhập mới vào tài khoản Cloudron của bạn.",
|
||||
"salutation": "Xin chào <%= user %>,",
|
||||
"subject": "[<%= cloudron %>] Có đăng nhập mới vào tài khoản của bạn",
|
||||
"notice": "Chhungs tôi nhận thấy một đăng nhập trên tài khoản Cloudron của bạn từ một thiết bị mới.",
|
||||
"notice": "Có một đăng nhập vào tài khoản Cloudron của bạn từ một thiết bị mới.",
|
||||
"action": "Nếu người đó là bạn, bạn có thể thoải mái bỏ qua email này. Nếu đó không phải là bạn, bạn nên đổi mật khẩu của bạn ngay bây giờ."
|
||||
},
|
||||
"supportConfig": {
|
||||
|
||||
@@ -798,8 +798,6 @@
|
||||
},
|
||||
"updates": {
|
||||
"title": "更新",
|
||||
"autoUpdateDisabled": "平台和应用的自动更新已 <b>停用</b>。",
|
||||
"currentSchedule": "当前平台和应用的自动更新计划是",
|
||||
"version": "平台版本",
|
||||
"showLogsAction": "显示日志",
|
||||
"changeScheduleAction": "修改计划",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="modal-body">
|
||||
<div ng-repeat="item in appPostInstallConfirm.app.checklist">
|
||||
<div class="checklist-item" ng-hide="item.acknowledged">
|
||||
{{ item.message }}
|
||||
<span ng-bind-html="item.message | markdown2html"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -632,7 +632,7 @@
|
||||
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="clone.portsEnabled[env]">
|
||||
{{ info.title }}
|
||||
<sup>
|
||||
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
|
||||
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}"><i class="fa fa-question-circle"></i></a>
|
||||
</sup>
|
||||
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
|
||||
</label>
|
||||
@@ -750,14 +750,14 @@
|
||||
|
||||
<div ng-repeat="(key, item) in app.checklist">
|
||||
<div class="checklist-item" ng-hide="item.acknowledged">
|
||||
{{ item.message }}
|
||||
<span ng-bind-html="item.message | markdown2html"></span>
|
||||
<button class="btn btn-xs btn-default" style="margin-left: 10px;" ng-click="info.checklistAck(item, key)">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-repeat="(key, item) in app.checklist" ng-show="info.showDoneChecklist">
|
||||
<div class="checklist-item checklist-item-acknowledged" ng-show="item.acknowledged">
|
||||
{{ item.message }}<br/>
|
||||
<span ng-bind-html="item.message | markdown2html"></span>
|
||||
<span class="text-muted text-small">{{ item.changedBy }} {{ item.changedAt | prettyDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -775,38 +775,38 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'app.updates.info.appId' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right">
|
||||
<span>{{ app.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'app.updates.info.packageVersion' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right">
|
||||
<span ng-show="app.appStoreId"><a ng-href="/#/appstore/{{app.manifest.id}}?version={{app.manifest.version}}">{{ app.manifest.id }}@{{ app.manifest.version }}</a></span>
|
||||
<span ng-show="!app.appStoreId">{{ app.manifest.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'app.updates.info.installedAt' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right">
|
||||
<span>{{ app.creationTime | prettyDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'app.updates.info.lastUpdated' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right">
|
||||
<span>{{ app.updateTime | prettyDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -938,7 +938,7 @@
|
||||
<label class="control-label" style="width: 100%" for="locationPortInput{{env}}"><input type="checkbox" ng-model="location.portsEnabled[env]">
|
||||
{{ info.title }}
|
||||
<sup>
|
||||
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
|
||||
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}}"><i class="fa fa-question-circle"></i></a>
|
||||
</sup>
|
||||
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
|
||||
<span ng-show="info.portCount" style="display: block; float: right">{{ location.ports[env] }} to {{ location.ports[env] + info.portCount - 1 }} ({{ info.portCount }} ports)</span>
|
||||
|
||||
@@ -629,9 +629,12 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
|
||||
var nearest256m = Math.ceil(Math.max(result.memory, $scope.resources.currentMemoryLimit) / (256*1024*1024)) * 256 * 1024 * 1024;
|
||||
var startTick = app.manifest.memoryLimit || (256 * 1024 * 1024);
|
||||
|
||||
for (var i = startTick; i <= nearest256m; i *= 2) {
|
||||
// code below ensure we atleast have 2 ticks to keep the slider usable
|
||||
$scope.resources.memoryTicks.push(startTick); // start tick
|
||||
for (var i = startTick * 2; i < nearest256m; i *= 2) {
|
||||
$scope.resources.memoryTicks.push(i);
|
||||
}
|
||||
$scope.resources.memoryTicks.push(nearest256m); // end tick
|
||||
});
|
||||
|
||||
// for firefox widget update
|
||||
|
||||
@@ -19,9 +19,13 @@
|
||||
-->
|
||||
<div ng-bind-html="appPostInstallConfirm.message | markdown2html"></div>
|
||||
<div ng-show="appPostInstallConfirm.app.manifest.documentationUrl" ng-bind-html="'app.appInfo.appDocsUrl' | tr:{ docsUrl: appPostInstallConfirm.app.manifest.documentationUrl, title: appPostInstallConfirm.app.manifest.title, forumUrl: (appPostInstallConfirm.app.manifest.forumUrl || 'https://forum.cloudron.io') }"></div>
|
||||
|
||||
<div style="margin-top: 10px; margin-bottom: 5px;" ng-show="pendingChecklistItems(appPostInstallConfirm.app)">
|
||||
<label class="control-label">{{ 'app.appInfo.checklist' | tr }}</label>
|
||||
</div>
|
||||
<div ng-repeat="item in appPostInstallConfirm.app.checklist">
|
||||
<div class="checklist-item" ng-hide="item.acknowledged">
|
||||
{{ item.message }}
|
||||
<span ng-bind-html="item.message | markdown2html"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="appInstall.portsEnabled[env]">
|
||||
{{ info.title }}
|
||||
<sup>
|
||||
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
|
||||
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}}"><i class="fa fa-question-circle"></i></a>
|
||||
</sup>
|
||||
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
|
||||
</label>
|
||||
@@ -407,7 +407,7 @@
|
||||
<center>
|
||||
<a href="" ng-click="appstoreLogin.setupType = 'signup'" ng-show="appstoreLogin.setupType === 'login'">{{ 'appstore.accountDialog.switchToSignUpAction' | tr }}</a>
|
||||
<a href="" ng-click="appstoreLogin.setupType = 'login'" ng-show="appstoreLogin.setupType === 'signup' || appstoreLogin.setupType === 'setupToken'">{{ 'appstore.accountDialog.switchToLoginAction' | tr }}</a>
|
||||
<span ng-show="appstoreLogin.setupType !== 'setupToken'"> or <a href="" ng-click="appstoreLogin.setupType = 'setupToken'">use a setup token</a></span>
|
||||
<span ng-show="appstoreLogin.setupType !== 'setupToken'"> or <a href="" ng-click="appstoreLogin.setupType = 'setupToken'">Use a setup token</a></span>
|
||||
</center>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
angular.module('Application').controller('AppStoreController', ['$scope', '$translate', '$location', '$timeout', '$routeParams', 'Client', function ($scope, $translate, $location, $timeout, $routeParams, Client) {
|
||||
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
|
||||
|
||||
$scope.HOST_PORT_MIN = 1024;
|
||||
$scope.HOST_PORT_MIN = 1;
|
||||
$scope.HOST_PORT_MAX = 65535;
|
||||
|
||||
$scope.ready = false;
|
||||
|
||||
@@ -476,7 +476,7 @@
|
||||
<span>{{ prettyProviderName(backupConfig.provider) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row" ng-show="backupConfig.provider !== 'noop'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'backups.location.location' | tr }}</span>
|
||||
</div>
|
||||
@@ -542,20 +542,20 @@
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom: 15px;">
|
||||
<p>{{ 'backups.schedule.description' | tr }}</p>
|
||||
<p ng-bind-html=" 'backups.schedule.description' | tr "></p>
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'backups.schedule.schedule' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right" style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">
|
||||
<span>{{ prettyBackupSchedule(backupPolicy.currentPolicy.schedule) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'backups.schedule.retentionPolicy' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right">
|
||||
<span>{{ prettyBackupRetention(backupPolicy.currentPolicy.retention) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
/* global $, angular, TASK_TYPES, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS, APP_TYPES */
|
||||
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR , REGIONS_CONTABO */
|
||||
/* global document, window, FileReader */
|
||||
|
||||
angular.module('Application').controller('BackupsController', ['$scope', '$location', '$rootScope', '$timeout', 'Client', function ($scope, $location, $rootScope, $timeout, Client) {
|
||||
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
|
||||
@@ -15,6 +16,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
|
||||
$scope.memory = null; // { memory, swap }
|
||||
$scope.mountStatus = null; // { state, message }
|
||||
$scope.manualBackupApps = [];
|
||||
$scope.currentTimeZone = '';
|
||||
|
||||
$scope.backupConfig = {};
|
||||
$scope.backups = [];
|
||||
@@ -785,7 +787,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
|
||||
$scope.backups.forEach(function (backup) {
|
||||
backup.contents = []; // { id, label, fqdn }
|
||||
backup.dependsOn.forEach(function (appBackupId) {
|
||||
let match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
|
||||
const match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
|
||||
if (!match) return; // for example, 'mail'
|
||||
const app = appsById[match[1]];
|
||||
if (app) {
|
||||
|
||||
@@ -108,79 +108,74 @@
|
||||
<h4 class="modal-title">{{ 'email.editMailboxDialog.title' | tr:{ name: mailboxes.edit.name, domain: domain.domain } }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form name="mailboxedit_form" role="form" ng-submit="mailboxes.edit.submit()" autocomplete="off">
|
||||
<input type="password" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label class="control-label">{{ 'email.editMailboxDialog.owner' | tr }}</label>
|
||||
<div class="control-label">
|
||||
<multiselect ng-model="mailboxes.edit.owner" options="o.display for o in owners" data-compare-by="name" data-header-key="header" data-divider-key="divider" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">{{ 'email.editMailboxDialog.owner' | tr }}</label>
|
||||
<div class="control-label">
|
||||
<multiselect ng-model="mailboxes.edit.owner" options="o.display for o in owners" data-compare-by="name" data-header-key="header" data-divider-key="divider" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group aliases">
|
||||
<label class="control-label">{{ 'email.editMailboxDialog.aliases' | tr }}</label>
|
||||
<div class="has-error" ng-show="mailboxes.edit.error">{{ mailboxes.edit.error.message }}</div>
|
||||
<div class="form-group aliases">
|
||||
<label class="control-label">{{ 'email.editMailboxDialog.aliases' | tr }}</label>
|
||||
<div class="has-error" ng-show="mailboxes.edit.error">{{ mailboxes.edit.error.message }}</div>
|
||||
|
||||
<div class="row" ng-repeat="alias in mailboxes.edit.aliases | orderBy:'reversedSortingNotation'">
|
||||
<div class="col col-lg-11">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control input-sm" ng-model="alias.name" autofocus>
|
||||
<div class="row" ng-repeat="alias in mailboxes.edit.aliases | orderBy:'reversedSortingNotation'">
|
||||
<div class="col col-lg-11">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control input-sm" ng-model="alias.name">
|
||||
|
||||
<div class="input-group-btn">
|
||||
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
|
||||
<span>@{{ alias.domain }}</span>
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right" role="menu">
|
||||
<li ng-repeat="incomingDomain in incomingDomains">
|
||||
<a href="" ng-click="alias.domain = incomingDomain.domain">{{ incomingDomain.domain }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="input-group-btn">
|
||||
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
|
||||
<span>@{{ alias.domain }}</span>
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right" role="menu">
|
||||
<li ng-repeat="incomingDomain in incomingDomains">
|
||||
<a href="" ng-click="alias.domain = incomingDomain.domain">{{ incomingDomain.domain }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-lg-1">
|
||||
<button class="btn btn-danger btn-sm" ng-click="mailboxes.edit.delAlias($event, alias)"><i class="far fa-trash-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="mailboxes.edit.aliases.length === 0">
|
||||
{{ 'email.editMailboxDialog.noAliases' | tr }} <a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAliasAction' | tr }}</a>
|
||||
</div>
|
||||
<div ng-show="mailboxes.edit.aliases.length > 0" style="margin-top: 5px;">
|
||||
<a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAnotherAliasAction' | tr }}</a>
|
||||
<div class="col col-lg-1">
|
||||
<button class="btn btn-danger btn-sm" ng-click="mailboxes.edit.delAlias($event, alias)"><i class="far fa-trash-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="storageQuota">
|
||||
<input id="storageQuota" type="checkbox" ng-model="mailboxes.edit.storageQuotaEnabled">
|
||||
{{ 'email.editMailboxDialog.enableStorageQuota' | tr }} <b ng-hide="!mailboxes.edit.storageQuotaEnabled">: {{ mailboxes.edit.storageQuota | prettyDecimalSize }}</b>
|
||||
</input>
|
||||
</label>
|
||||
<input type="range" id="storageQuota" ng-disabled="!mailboxes.edit.storageQuotaEnabled" ng-model="mailboxes.edit.storageQuota" step="500000000" min="{{ storageQuotaTicks[0] }}" max="{{ storageQuotaTicks[storageQuotaTicks.length-1] }}" list="storageQuotaTicks" />
|
||||
<datalist id="storageQuotaTicks">
|
||||
<option ng-repeat="quota in storageQuotaTicks" value="{{ quota }}"></option>
|
||||
</datalist>
|
||||
<div ng-show="mailboxes.edit.aliases.length === 0">
|
||||
{{ 'email.editMailboxDialog.noAliases' | tr }} <a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAliasAction' | tr }}</a>
|
||||
</div>
|
||||
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" ng-model="mailboxes.edit.enablePop3"> {{ 'email.updateMailboxDialog.enablePop3' | tr }}</input>
|
||||
</label>
|
||||
<div ng-show="mailboxes.edit.aliases.length > 0" style="margin-top: 5px;">
|
||||
<a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAnotherAliasAction' | tr }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" ng-model="mailboxes.edit.active"> {{ 'email.updateMailboxDialog.activeCheckbox' | tr }}</input>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="storageQuota">
|
||||
<input id="storageQuota" type="checkbox" ng-model="mailboxes.edit.storageQuotaEnabled">
|
||||
{{ 'email.editMailboxDialog.enableStorageQuota' | tr }} <b ng-hide="!mailboxes.edit.storageQuotaEnabled">: {{ mailboxes.edit.storageQuota | prettyDecimalSize }}</b>
|
||||
</input>
|
||||
</label>
|
||||
<input type="range" id="storageQuota" ng-disabled="!mailboxes.edit.storageQuotaEnabled" ng-model="mailboxes.edit.storageQuota" step="500000000" min="{{ storageQuotaTicks[0] }}" max="{{ storageQuotaTicks[storageQuotaTicks.length-1] }}" list="storageQuotaTicks" />
|
||||
<datalist id="storageQuotaTicks">
|
||||
<option ng-repeat="quota in storageQuotaTicks" value="{{ quota }}"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<input class="hide" type="submit" ng-disabled="mailboxedit_form.$invalid || mailboxes.edit.busy || !mailboxes.edit.owner"/>
|
||||
</form>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" ng-model="mailboxes.edit.enablePop3"> {{ 'email.updateMailboxDialog.enablePop3' | tr }}</input>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" ng-model="mailboxes.edit.active"> {{ 'email.updateMailboxDialog.activeCheckbox' | tr }}</input>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
|
||||
<button type="button" class="btn btn-success" ng-click="mailboxes.edit.submit()" ng-disabled="mailboxedit_form.$invalid || mailboxes.edit.busy || !mailboxes.edit.owner"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxes.edit.busy"></i> {{ 'main.dialog.save' | tr }}</button>
|
||||
<button type="button" class="btn btn-success" ng-click="mailboxes.edit.submit()" ng-disabled="mailboxes.edit.busy || !mailboxes.edit.owner"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxes.edit.busy"></i> {{ 'main.dialog.save' | tr }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,44 +205,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal import mailboxes -->
|
||||
<div class="modal fade" id="mailboxImportModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ 'email.mailboxImportDialog.title' | tr }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div ng-show="!mailboxImport.done">
|
||||
<div ng-show="!mailboxImport.busy">
|
||||
<p ng-bind-html=" 'email.mailboxImportDialog.description' | tr:{ docsLink: 'https://cloudron.io/documentation/email/#import-mailboxes' } "></p>
|
||||
<input type="file" style="display: none;" id="mailboxImportFileInput" accept="application/json,text/csv"/>
|
||||
<button class="btn btn-primary" ng-click="mailboxImport.openFileInput()">{{ 'email.mailboxImportDialog.fileInput' | tr }}</button>
|
||||
<br/>
|
||||
<br/>
|
||||
<p class="text-danger" ng-show="mailboxImport.error.file">{{ mailboxImport.error.file }}</p>
|
||||
<p class="text-info" ng-show="mailboxImport.mailboxes.length">{{ 'email.mailboxImportDialog.mailboxesFound' | tr:{ count: mailboxImport.mailboxes.length } }}</p>
|
||||
</div>
|
||||
<div ng-show="mailboxImport.busy" class="progress progress-striped active">
|
||||
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ mailboxImport.percent }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="mailboxImport.done">
|
||||
<p>{{ 'email.mailboxImportDialog.success' | tr:{ count: mailboxImport.success } }}</p>
|
||||
<div ng-show="mailboxImport.error.import.length">
|
||||
<p class="text-danger">{{ 'email.mailboxImportDialog.failed' | tr }}</p>
|
||||
<div ng-repeat="tmp in mailboxImport.error.import"><b>{{ tmp.mailbox.name }}@{{ tmp.mailbox.domain }}:</b> {{ tmp.error.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
|
||||
<button type="button" class="btn btn-primary" ng-click="mailboxImport.import()" ng-show="!mailboxImport.done" ng-disabled="mailboxImport.busy || !mailboxImport.mailboxes.length"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxImport.busy"></i> {{ 'email.mailboxImportDialog.importAction' | tr }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal add mailinglist -->
|
||||
<div class="modal fade" id="mailinglistAddModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
@@ -417,18 +374,6 @@
|
||||
<div class="text-left">
|
||||
<h3 style="margin-bottom: 15px;">{{ 'email.incoming.mailboxes.title' | tr }}
|
||||
<button class="btn btn-primary btn-outline pull-right" ng-click="mailboxes.add.show()" ng-disabled="!domain.mailConfig.enabled" tooltip-enable="!domain.mailConfig.enabled" uib-tooltip="{{ 'email.incoming.mailboxes.disabledTooltip' | tr }}"><i class="fa fa-inbox"></i> {{ 'email.incoming.mailboxes.addAction' | tr }}</button>
|
||||
<div class="btn-group pull-right" style="margin-left: 5px;">
|
||||
<button class="btn btn-default" ng-click="mailboxImport.show()" uib-tooltip="{{ 'email.incoming.mailboxes.importTooltip' | tr }}" tooltip-append-to-body="true"><i class="fas fa-download"></i></button>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'email.incoming.mailboxes.exportTooltip' | tr }}" tooltip-append-to-body="true">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="" ng-click="mailboxExport('csv')">{{ 'email.incoming.mailboxes.mailboxExport.csv' | tr }}</a></li>
|
||||
<li><a href="" ng-click="mailboxExport('json')">{{ 'email.incoming.mailboxes.mailboxExport.json' | tr }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<input class="form-control pull-right" style="width: 200px;" placeholder="{{ 'main.searchPlaceholder' | tr }}" type="text" ng-model="mailboxes.search" ng-model-options="{ debounce: 1000 }" ng-change="mailboxes.updateFilter()" />
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -377,172 +377,6 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
|
||||
}
|
||||
};
|
||||
|
||||
$scope.mailboxImport = {
|
||||
busy: false,
|
||||
done: false,
|
||||
error: null,
|
||||
percent: 0,
|
||||
success: 0,
|
||||
mailboxes: [],
|
||||
|
||||
reset: function () {
|
||||
$scope.mailboxImport.busy = false;
|
||||
$scope.mailboxImport.error = null;
|
||||
$scope.mailboxImport.mailboxes = [];
|
||||
$scope.mailboxImport.percent = 0;
|
||||
$scope.mailboxImport.success = 0;
|
||||
$scope.mailboxImport.done = false;
|
||||
},
|
||||
|
||||
handleFileChanged: function () {
|
||||
$scope.mailboxImport.reset();
|
||||
|
||||
var fileInput = document.getElementById('mailboxImportFileInput');
|
||||
if (!fileInput.files || !fileInput.files[0]) return;
|
||||
|
||||
var file = fileInput.files[0];
|
||||
if (file.type !== 'application/json' && file.type !== 'text/csv') return console.log('Unsupported file type.');
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', function () {
|
||||
$scope.$apply(function () {
|
||||
$scope.mailboxImport.mailboxes = [];
|
||||
var mailboxes = [];
|
||||
|
||||
if (file.type === 'text/csv') {
|
||||
var lines = reader.result.split('\n');
|
||||
if (lines.length === 0) return $scope.mailboxImport.error = { file: 'Imported file has no lines' };
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
var items = line.split(',');
|
||||
if (items.length !== 4) {
|
||||
$scope.mailboxImport.error = { file: 'Line ' + (i+1) + ' has wrong column count. Expecting 4' };
|
||||
return;
|
||||
}
|
||||
mailboxes.push({
|
||||
name: items[0].trim(),
|
||||
domain: items[1].trim(),
|
||||
owner: items[2].trim(),
|
||||
ownerType: items[3].trim(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
mailboxes = JSON.parse(reader.result).map(function (mailbox) {
|
||||
return {
|
||||
name: mailbox.name,
|
||||
domain: mailbox.domain,
|
||||
owner: mailbox.owner,
|
||||
ownerType: mailbox.ownerType
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse mailboxes.', e);
|
||||
$scope.mailboxImport.error = { file: 'Imported file is not valid JSON' };
|
||||
}
|
||||
}
|
||||
|
||||
$scope.mailboxImport.mailboxes = mailboxes;
|
||||
});
|
||||
}, false);
|
||||
reader.readAsText(file);
|
||||
},
|
||||
|
||||
show: function () {
|
||||
$scope.mailboxImport.reset();
|
||||
|
||||
// named so no duplactes
|
||||
document.getElementById('mailboxImportFileInput').addEventListener('change', $scope.mailboxImport.handleFileChanged);
|
||||
|
||||
$('#mailboxImportModal').modal('show');
|
||||
},
|
||||
|
||||
openFileInput: function () {
|
||||
$('#mailboxImportFileInput').click();
|
||||
},
|
||||
|
||||
import: function () {
|
||||
$scope.mailboxImport.percent = 0;
|
||||
$scope.mailboxImport.success = 0;
|
||||
$scope.mailboxImport.done = false;
|
||||
$scope.mailboxImport.error = { import: [] };
|
||||
$scope.mailboxImport.busy = true;
|
||||
|
||||
var processed = 0;
|
||||
|
||||
async.eachSeries($scope.mailboxImport.mailboxes, function (mailbox, callback) {
|
||||
var owner = $scope.owners.find(function (o) { return o.display === mailbox.owner && o.type === mailbox.ownerType; }); // owner may not exist
|
||||
if (!owner) {
|
||||
$scope.mailboxImport.error.import.push({ error: new Error('Could not detect owner'), mailbox: mailbox });
|
||||
++processed;
|
||||
$scope.mailboxImport.percent = 100 * processed / $scope.mailboxImport.mailboxes.length;
|
||||
return callback();
|
||||
}
|
||||
|
||||
Client.addMailbox(mailbox.domain, mailbox.name, owner.id, mailbox.ownerType, function (error) {
|
||||
if (error) $scope.mailboxImport.error.import.push({ error: error, mailbox: mailbox });
|
||||
else ++$scope.mailboxImport.success;
|
||||
|
||||
++processed;
|
||||
$scope.mailboxImport.percent = 100 * processed / $scope.mailboxImport.mailboxes.length;
|
||||
|
||||
callback();
|
||||
});
|
||||
}, function (error) {
|
||||
if (error) return console.error(error);
|
||||
|
||||
$scope.mailboxImport.busy = false;
|
||||
$scope.mailboxImport.done = true;
|
||||
if ($scope.mailboxImport.success) $scope.mailboxes.refresh();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$scope.mailboxExport = function (type) {
|
||||
// FIXME only does first 10k mailboxes
|
||||
Client.listMailboxes($scope.domain.domain, '', 1, 10000, function (error, result) {
|
||||
if (error) {
|
||||
Client.error('Failed to list mailboxes. Full error in the webinspector.');
|
||||
return console.error('Failed to list mailboxes.', error);
|
||||
}
|
||||
|
||||
var content = '';
|
||||
|
||||
if (type === 'json') {
|
||||
content = JSON.stringify(result.map(function (mailbox) {
|
||||
var owner = $scope.owners.find(function (o) { return o.id === mailbox.ownerId; }); // owner may not exist
|
||||
|
||||
return {
|
||||
name: mailbox.name,
|
||||
domain: mailbox.domain,
|
||||
owner: owner ? owner.display : '', // this meta property is set when we get the user list
|
||||
ownerType: owner ? owner.type : '',
|
||||
active: mailbox.active,
|
||||
aliases: mailbox.aliases
|
||||
};
|
||||
}), null, 2);
|
||||
} else if (type === 'csv') {
|
||||
content = result.map(function (mailbox) {
|
||||
var owner = $scope.owners.find(function (o) { return o.id === mailbox.ownerId; }); // owner may not exist
|
||||
|
||||
var aliases = mailbox.aliases.map(function (a) { return a.name + '@' + a.domain; }).join(' ');
|
||||
return [ mailbox.name, mailbox.domain, owner ? owner.display : '', owner ? owner.type : '', aliases, mailbox.active ].join(',');
|
||||
}).join('\n');
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
var file = new Blob([ content ], { type: type === 'json' ? 'application/json' : 'text/csv' });
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(file);
|
||||
a.download = $scope.domain.domain.replaceAll('.','_') + '-mailboxes.' + type;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.mailboxes = {
|
||||
mailboxes: [],
|
||||
search: '',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="content">
|
||||
<div class="content content-large">
|
||||
|
||||
<div class="text-left">
|
||||
<h1>{{ 'notifications.title' | tr }}
|
||||
@@ -15,7 +15,7 @@
|
||||
<h2><i class="fa fa-circle-notch fa-spin"></i></h2>
|
||||
</div>
|
||||
|
||||
<div class="card" ng-hide="busy || notifications.length">
|
||||
<div class="card card-large" ng-hide="busy || notifications.length">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<h3 class="text-center" style="margin: 20px;">{{ 'notifications.nonePending' | tr }}</h3>
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificationTypeToColor) }">
|
||||
<div class="card card-large notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificationTypeToColor) }">
|
||||
<div class="row">
|
||||
<div class="col-xs-12" ng-class="{ 'notification-details': notification.detailsShown }">
|
||||
<span class="notification-title">{{ notification.title }}</span> <small class="text-muted" uib-tooltip="{{ notification.creationTime | prettyLongDate }}">{{ notification.creationTime | prettyDate }}</small>
|
||||
|
||||
@@ -272,22 +272,31 @@
|
||||
<div class="card" style="margin-bottom: 15px;">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<span ng-show="updateSchedule.currentPattern !== 'never'">{{ 'settings.updates.currentSchedule' | tr }} <b>{{ prettyAutoUpdateSchedule(updateSchedule.currentPattern) }}</b></span>
|
||||
<span ng-show="updateSchedule.currentPattern === 'never'" ng-bind-html=" 'settings.updates.autoUpdateDisabled' | tr "></span>
|
||||
<span ng-bind-html=" 'settings.updates.description' | tr "></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'settings.updates.version' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right">
|
||||
v{{ config.version }} ({{ config.ubuntuVersion }})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'settings.updates.schedule' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-8 text-right" style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">
|
||||
<span ng-show="updateSchedule.currentPattern !== 'never'">{{ prettyAutoUpdateSchedule(updateSchedule.currentPattern) }}</span>
|
||||
<span ng-show="updateSchedule.currentPattern === 'never'">{{ 'settings.updates.disabled' | tr }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<br/>
|
||||
<div ng-if="update.busy" class="col-md-12" style="margin-bottom: 10px;">
|
||||
|
||||
@@ -292,48 +292,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal user import -->
|
||||
<div class="modal fade" id="userImportModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ 'users.userImportDialog.title' | tr }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div ng-show="!userImport.done">
|
||||
<div ng-show="!userImport.busy">
|
||||
<p ng-bind-html=" 'users.userImportDialog.description' | tr:{ docsLink: 'https://docs.cloudron.io/user-management/#import-users' } "></p>
|
||||
<input type="file" style="display: none;" id="userImportFileInput" accept="application/json,text/csv"/>
|
||||
<button class="btn btn-primary" ng-click="userImport.openFileInput()">{{ 'users.userImportDialog.fileInput' | tr }}</button>
|
||||
<br/>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" ng-model="userImport.sendInvite" id="inputUserImportSendInvite"> {{ 'users.userImportDialog.sendInviteCheckbox' | tr }}
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-danger" ng-show="userImport.error.file">{{ userImport.error.file }}</p>
|
||||
<p class="text-info" ng-show="userImport.users.length">{{ 'users.userImportDialog.usersFound' | tr:{ count: userImport.users.length } }}</p>
|
||||
</div>
|
||||
<div ng-show="userImport.busy" class="progress progress-striped active">
|
||||
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ userImport.percent }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="userImport.done">
|
||||
<p>{{ 'users.userImportDialog.success' | tr:{ count: userImport.success } }}</p>
|
||||
<div ng-show="userImport.error.import.length">
|
||||
<p class="text-danger">{{ 'users.userImportDialog.failed' | tr }}</p>
|
||||
<div ng-repeat="tmp in userImport.error.import"><b>{{ tmp.user.email }}:</b> {{ tmp.error.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
|
||||
<button type="button" class="btn btn-primary" ng-click="userImport.import()" ng-show="!userImport.done" ng-disabled="userImport.busy || !userImport.users.length"><i class="fa fa-circle-notch fa-spin" ng-show="userImport.busy"></i> {{ 'users.userImportDialog.importAction' | tr }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal password reset -->
|
||||
<div class="modal fade" id="passwordResetModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
@@ -454,19 +412,6 @@
|
||||
<input type="text" id="userSearchInput" class="form-control" style="max-width: 350px;" ng-model="userSearchString" ng-model-options="{ debounce: 1000 }" ng-change="updateFilter()" placeholder="{{ 'main.searchPlaceholder' | tr }}"/>
|
||||
<multiselect ng-model="userStateFilter" ms-header="{{ 'apps.stateFilterHeader' | tr }}" ms-selected="{{ userStateFilter }}" options="state.label for state in userStates" data-multiple="false"></multiselect>
|
||||
<div style="flex-grow: 1;"></div>
|
||||
<!-- import/export buttons are hidden until we figure what the exact use case is -->
|
||||
<div class="btn-group" ng-hide="true">
|
||||
<button class="btn btn-default" ng-click="userImport.show()" uib-tooltip="{{ 'users.userImport.tooltip' | tr }}" tooltip-append-to-body="true"><i class="fas fa-download"></i></button>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'users.userExport.tooltip' | tr }}" tooltip-append-to-body="true">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="" ng-click="userExport('csv')">{{ 'users.userExport.csv' | tr }}</a></li>
|
||||
<li><a href="" ng-click="userExport('json')">{{ 'users.userExport.json' | tr }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-outline" ng-click="userAdd.show()">
|
||||
<i class="fa fa-user-plus"></i> {{ 'users.newUserAction' | tr }}
|
||||
</button>
|
||||
|
||||
@@ -67,171 +67,6 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
return true;
|
||||
};
|
||||
|
||||
$scope.userImport = {
|
||||
busy: false,
|
||||
done: false,
|
||||
error: null,
|
||||
percent: 0,
|
||||
success: 0,
|
||||
users: [],
|
||||
sendInvite: false,
|
||||
|
||||
reset: function () {
|
||||
$scope.userImport.busy = false;
|
||||
$scope.userImport.error = null;
|
||||
$scope.userImport.users = [];
|
||||
$scope.userImport.percent = 0;
|
||||
$scope.userImport.success = 0;
|
||||
$scope.userImport.done = false;
|
||||
$scope.userImport.sendInvite = false;
|
||||
},
|
||||
|
||||
handleFileChanged: function () {
|
||||
$scope.userImport.reset();
|
||||
|
||||
var fileInput = document.getElementById('userImportFileInput');
|
||||
if (!fileInput.files || !fileInput.files[0]) return;
|
||||
|
||||
var file = fileInput.files[0];
|
||||
if (file.type !== 'application/json' && file.type !== 'text/csv') return console.log('Unsupported file type.');
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', function () {
|
||||
$scope.$apply(function () {
|
||||
$scope.userImport.users = [];
|
||||
var users = [];
|
||||
if (file.type === 'text/csv') {
|
||||
var lines = reader.result.split('\n');
|
||||
if (lines.length === 0) return $scope.userImport.error = { file: 'Imported file has no lines' };
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
var items = line.split(',');
|
||||
if (items.length !== 5) {
|
||||
$scope.userImport.error = { file: 'Line ' + (i+1) + ' has wrong column count. Expecting 5' };
|
||||
return;
|
||||
}
|
||||
users.push({
|
||||
username: items[0].trim(),
|
||||
email: items[1].trim(),
|
||||
fallbackEmail: items[2].trim(),
|
||||
displayName: items[3].trim(),
|
||||
role: items[4].trim()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
users = JSON.parse(reader.result).map(function (user) {
|
||||
return {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
fallbackEmail: user.fallbackEmail,
|
||||
displayName: user.displayName,
|
||||
role: user.role
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse users.', e);
|
||||
$scope.userImport.error = { file: 'Imported file is not valid JSON:' + e.message };
|
||||
}
|
||||
}
|
||||
$scope.userImport.users = users;
|
||||
});
|
||||
}, false);
|
||||
reader.readAsText(file);
|
||||
},
|
||||
|
||||
show: function () {
|
||||
$scope.userImport.reset();
|
||||
|
||||
// named so no duplactes
|
||||
document.getElementById('userImportFileInput').addEventListener('change', $scope.userImport.handleFileChanged);
|
||||
|
||||
$('#userImportModal').modal('show');
|
||||
},
|
||||
|
||||
openFileInput: function () {
|
||||
$('#userImportFileInput').click();
|
||||
},
|
||||
|
||||
import: function () {
|
||||
$scope.userImport.percent = 0;
|
||||
$scope.userImport.success = 0;
|
||||
$scope.userImport.done = false;
|
||||
$scope.userImport.error = { import: [] };
|
||||
$scope.userImport.busy = true;
|
||||
|
||||
var processed = 0;
|
||||
|
||||
async.eachSeries($scope.userImport.users, function (user, callback) {
|
||||
Client.addUser(user, function (error, userId) {
|
||||
if (error) $scope.userImport.error.import.push({ error: error, user: user });
|
||||
else ++$scope.userImport.success;
|
||||
|
||||
++processed;
|
||||
$scope.userImport.percent = 100 * processed / $scope.userImport.users.length;
|
||||
|
||||
if (!error && $scope.userImport.sendInvite) {
|
||||
console.log('sending', userId, user.email);
|
||||
Client.sendInviteEmail(userId, user.email, function (error) {
|
||||
if (error) console.error('Failed to send invite.', error);
|
||||
});
|
||||
}
|
||||
|
||||
callback();
|
||||
});
|
||||
}, function (error) {
|
||||
if (error) return console.error(error);
|
||||
|
||||
$scope.userImport.busy = false;
|
||||
$scope.userImport.done = true;
|
||||
if ($scope.userImport.success) {
|
||||
refreshCurrentPage();
|
||||
refreshAllUsers();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// supported types are 'json' and 'csv'
|
||||
$scope.userExport = function (type) {
|
||||
Client.getAllUsers(function (error, result) {
|
||||
if (error) {
|
||||
Client.error('Failed to list users. Full error in the webinspector.');
|
||||
return console.error('Failed to list users.', error);
|
||||
}
|
||||
|
||||
var content = '';
|
||||
if (type === 'json') {
|
||||
content = JSON.stringify(result.map(function (user) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
fallbackEmail: user.fallbackEmail,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
active: user.active
|
||||
};
|
||||
}), null, 2);
|
||||
} else if (type === 'csv') {
|
||||
content = result.map(function (user) {
|
||||
return [ user.id, user.username, user.email, user.fallbackEmail, user.displayName, user.role, user.active ].join(',');
|
||||
}).join('\n');
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
var file = new Blob([ content ], { type: type === 'json' ? 'application/json' : 'text/csv' });
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(file);
|
||||
a.download = type === 'json' ? 'users.json' : 'users.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.userRemove = {
|
||||
busy: false,
|
||||
error: null,
|
||||
|
||||
22
frontend/eslint.config.js
Normal file
22
frontend/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import globals from 'globals';
|
||||
import js from '@eslint/js';
|
||||
import pluginVue from 'eslint-plugin-vue';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs['flat/essential'],
|
||||
{
|
||||
files: ["**/*.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
ecmaVersion: 13,
|
||||
sourceType: 'module'
|
||||
},
|
||||
rules: {
|
||||
semi: "error",
|
||||
"prefer-const": "error"
|
||||
}
|
||||
}
|
||||
];
|
||||
1913
frontend/package-lock.json
generated
1913
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "my-vue-app",
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@@ -9,24 +9,26 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/noto-sans": "^5.0.22",
|
||||
"@fontsource/noto-sans": "^5.1.0",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"anser": "^2.1.1",
|
||||
"combokeys": "^3.0.1",
|
||||
"filesize": "^10.1.4",
|
||||
"marked": "^13.0.2",
|
||||
"filesize": "^10.1.6",
|
||||
"marked": "^14.1.2",
|
||||
"moment": "^2.30.1",
|
||||
"pankow": "^1.6.8",
|
||||
"pankow-viewers": "^1.0.4",
|
||||
"superagent": "^9.0.2",
|
||||
"vue": "^3.4.33",
|
||||
"vue-i18n": "^9.13.1",
|
||||
"vue-router": "^4.4.0"
|
||||
"pankow": "^2.2.1",
|
||||
"pankow-viewers": "^1.0.7",
|
||||
"vue": "^3.5.6",
|
||||
"vue-i18n": "^10.0.1",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"vite": "^5.3.4"
|
||||
"@eslint/js": "^9.10.0",
|
||||
"@vitejs/plugin-vue": "^5.1.3",
|
||||
"eslint": "^9.10.0",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"vite": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
<Button icon="fa-solid fa-eraser" @click="onClear()" style="margin-right: 5px">{{ $t('logs.clear') }}</Button>
|
||||
<Button :href="downloadUrl" target="_blank" icon="fa-solid fa-download">{{ $t('logs.download') }}</Button>
|
||||
|
||||
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" v-show="showRestart" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
|
||||
<Button :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="showTerminal" secondary icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
|
||||
<Button :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="showFilemanager" secondary icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
|
||||
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" v-show="showRestart" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
|
||||
<Button :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="showTerminal" secondary tool icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
|
||||
<Button :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="showFilemanager" secondary tool icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
|
||||
</template>
|
||||
</TopBar>
|
||||
</template>
|
||||
@@ -31,7 +31,7 @@ import { Button, TopBar, MainLayout } from 'pankow';
|
||||
import LogsModel from '../models/LogsModel.js';
|
||||
import AppModel from '../models/AppModel.js';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin ;
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin;
|
||||
|
||||
export default {
|
||||
name: 'LogsViewer',
|
||||
@@ -43,7 +43,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
accessToken: localStorage.token,
|
||||
apiOrigin: API_ORIGIN || '',
|
||||
logsModel: null,
|
||||
appModel: null,
|
||||
busyRestart: false,
|
||||
@@ -59,7 +58,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
onClear() {
|
||||
this.logLines = [];
|
||||
while (this.$refs.linesContainer.firstChild) this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
|
||||
},
|
||||
onDownload() {
|
||||
this.logsModel.download();
|
||||
@@ -113,10 +112,10 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logsModel = LogsModel.create(this.apiOrigin, this.accessToken, this.type, this.id);
|
||||
this.logsModel = LogsModel.create(API_ORIGIN, this.accessToken, this.type, this.id);
|
||||
|
||||
if (this.type === 'app') {
|
||||
this.appModel = AppModel.create(this.apiOrigin, this.accessToken, this.id);
|
||||
this.appModel = AppModel.create(API_ORIGIN, this.accessToken, this.id);
|
||||
|
||||
try {
|
||||
const app = await this.appModel.get();
|
||||
@@ -145,7 +144,6 @@ export default {
|
||||
if (lines < maxLines) ++lines;
|
||||
else this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
|
||||
|
||||
// this.logLines.push({ time, html});
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = 'log-line';
|
||||
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp] ' }</span> <span>${line.html}</span>`;
|
||||
@@ -161,7 +159,7 @@ export default {
|
||||
this.logsModel.stream((time, html) => {
|
||||
newLogLines.push({ time, html });
|
||||
}, function (error) {
|
||||
console.error('Failed to start log stream:', error);
|
||||
newLogLines.push({ time: error.time, html: error.html });
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
<Button style="margin-left: 20px;" :disabled="!connected" @click="onUpload" icon="fa-solid fa-upload">{{ $t('terminal.uploadTo', { path: '/app/data/' }) }}</Button>
|
||||
<Button :disabled="!connected" @click="onDownload" icon="fa-solid fa-download">{{ $t('terminal.downloadAction') }}</Button>
|
||||
|
||||
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
|
||||
<Button v-show="showFilemanager" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" secondary icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
|
||||
<Button :href="'/frontend/logs.html?appId=' + id" target="_blank" secondary icon="fa-solid fa-align-left" :title="$t('logs.title')" />
|
||||
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
|
||||
<Button v-show="showFilemanager" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" secondary tool icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
|
||||
<Button :href="'/frontend/logs.html?appId=' + id" target="_blank" secondary tool icon="fa-solid fa-align-left" :title="$t('logs.title')" />
|
||||
</template>
|
||||
</TopBar>
|
||||
</template>
|
||||
@@ -48,9 +48,7 @@
|
||||
|
||||
<script>
|
||||
|
||||
import superagent from 'superagent';
|
||||
|
||||
import { Button, Dialog, FileUploader, InputDialog, MainLayout, TopBar } from 'pankow';
|
||||
import { fetcher, Button, Dialog, FileUploader, InputDialog, MainLayout, TopBar } from 'pankow';
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
@@ -75,7 +73,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
accessToken: localStorage.token,
|
||||
apiOrigin: API_ORIGIN || '',
|
||||
appModel: null,
|
||||
directoryModel: null,
|
||||
fatalError: false,
|
||||
@@ -117,7 +114,7 @@ export default {
|
||||
if (!downloadFileName) return;
|
||||
|
||||
try {
|
||||
const result = await superagent.head(`${this.apiOrigin}/api/v1/apps/${this.id}/download`).query({
|
||||
await fetcher.head(`${API_ORIGIN}/api/v1/apps/${this.id}/download`, {
|
||||
file: downloadFileName,
|
||||
access_token: this.accessToken
|
||||
});
|
||||
@@ -128,7 +125,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
this.downloadFileDownloadUrl = `${this.apiOrigin}/api/v1/apps/${this.id}/download?file=${encodeURIComponent(downloadFileName)}&access_token=${this.accessToken}`;
|
||||
this.downloadFileDownloadUrl = `${API_ORIGIN}/api/v1/apps/${this.id}/download?file=${encodeURIComponent(downloadFileName)}&access_token=${this.accessToken}`;
|
||||
|
||||
// we have to click the link to make the browser do the download
|
||||
// don't know how to prevent the browsers
|
||||
@@ -199,7 +196,7 @@ export default {
|
||||
|
||||
let execId;
|
||||
try {
|
||||
const result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.id}/exec`).query({ access_token: this.accessToken }).send({ cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' });
|
||||
const result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${this.id}/exec`, { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' }, { access_token: this.accessToken });
|
||||
execId = result.body.id;
|
||||
} catch (error) {
|
||||
console.error('Cannot create socket.', error);
|
||||
@@ -222,7 +219,7 @@ export default {
|
||||
});
|
||||
|
||||
// websocket cannot use relative urls
|
||||
const url = `${this.apiOrigin.replace('https', 'wss')}/api/v1/apps/${this.id}/exec/${execId}/startws?tty=true&rows=${this.terminal.rows}&columns=${this.terminal.cols}&access_token=${this.accessToken}`;
|
||||
const url = `${API_ORIGIN.replace('https', 'wss')}/api/v1/apps/${this.id}/exec/${execId}/startws?tty=true&rows=${this.terminal.rows}&columns=${this.terminal.cols}&access_token=${this.accessToken}`;
|
||||
this.socket = new WebSocket(url);
|
||||
|
||||
this.terminal.loadAddon(new AttachAddon(this.socket));
|
||||
@@ -263,8 +260,8 @@ export default {
|
||||
this.id = id;
|
||||
this.name = id;
|
||||
|
||||
this.appModel = create(this.apiOrigin, this.accessToken, this.id);
|
||||
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, `apps/${id}`);
|
||||
this.appModel = create(API_ORIGIN, this.accessToken, this.id);
|
||||
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, `apps/${id}`);
|
||||
|
||||
try {
|
||||
const app = await this.appModel.get();
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import './style.css';
|
||||
|
||||
import '@fontsource/noto-sans';
|
||||
|
||||
import superagent from 'superagent';
|
||||
|
||||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
|
||||
import i18n from './i18n.js';
|
||||
import FileManager from './FileManager.vue';
|
||||
import Home from './views/Home.vue';
|
||||
import Viewer from './views/Viewer.vue';
|
||||
@@ -25,53 +23,10 @@ const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
const translations = {};
|
||||
const i18n = createI18n({
|
||||
locale: 'en', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
messages: translations,
|
||||
// will replace our double {{}} to vue-i18n single brackets
|
||||
messageResolver: (keys, key) => {
|
||||
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
|
||||
|
||||
// fallback tr key
|
||||
if (message === null) message = key;
|
||||
|
||||
return message.replaceAll('{{', '{').replaceAll('}}', '}');
|
||||
}
|
||||
});
|
||||
|
||||
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
|
||||
(async function loadLanguages() {
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
|
||||
|
||||
async function loadLanguage(lang) {
|
||||
try {
|
||||
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
|
||||
|
||||
// we do not deliver as application/json :/
|
||||
translations[lang] = JSON.parse(result.text);
|
||||
} catch (e) {
|
||||
console.error(`Failed to load language file for ${lang}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
await loadLanguage('en');
|
||||
|
||||
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
|
||||
if (locale && locale !== 'en') {
|
||||
await loadLanguage(locale);
|
||||
|
||||
if (i18n.mode === 'legacy') {
|
||||
i18n.global.locale = locale;
|
||||
} else {
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
const app = createApp(FileManager);
|
||||
|
||||
app.use(i18n);
|
||||
app.use(await i18n());
|
||||
app.use(router);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
51
frontend/src/i18n.js
Normal file
51
frontend/src/i18n.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
|
||||
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { fetcher } from 'pankow';
|
||||
|
||||
const translations = {};
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'en', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
messages: translations,
|
||||
// will replace our double {{}} to vue-i18n single brackets
|
||||
messageResolver: function (keys, key) {
|
||||
const message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
|
||||
|
||||
// if not found return null to fallback to resolving for english
|
||||
if (message === null) return null;
|
||||
|
||||
return message.replaceAll('{{', '{').replaceAll('}}', '}');
|
||||
}
|
||||
});
|
||||
|
||||
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
|
||||
async function loadLanguage(lang) {
|
||||
try {
|
||||
const result = await fetcher.get(`${API_ORIGIN}/translation/${lang}.json`);
|
||||
translations[lang] = result.body;
|
||||
} catch (e) {
|
||||
console.error(`Failed to load language file for ${lang}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// load at least fallback english
|
||||
await loadLanguage('en');
|
||||
|
||||
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
|
||||
if (locale && locale !== 'en') {
|
||||
await loadLanguage(locale);
|
||||
|
||||
if (i18n.mode === 'legacy') {
|
||||
i18n.global.locale = locale;
|
||||
} else {
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
}
|
||||
|
||||
return i18n;
|
||||
}
|
||||
|
||||
export default main;
|
||||
@@ -1,61 +1,16 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import './style.css';
|
||||
|
||||
import '@fontsource/noto-sans';
|
||||
|
||||
import superagent from 'superagent';
|
||||
|
||||
import i18n from './i18n.js';
|
||||
import LogsViewer from './components/LogsViewer.vue';
|
||||
|
||||
const translations = {};
|
||||
const i18n = createI18n({
|
||||
locale: 'en', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
messages: translations,
|
||||
// will replace our double {{}} to vue-i18n single brackets
|
||||
messageResolver: (keys, key) => {
|
||||
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
|
||||
|
||||
// fallback tr key
|
||||
if (message === null) message = key;
|
||||
|
||||
return message.replaceAll('{{', '{').replaceAll('}}', '}');
|
||||
}
|
||||
});
|
||||
|
||||
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
|
||||
(async function loadLanguages() {
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
|
||||
|
||||
async function loadLanguage(lang) {
|
||||
try {
|
||||
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
|
||||
|
||||
// we do not deliver as application/json :/
|
||||
translations[lang] = JSON.parse(result.text);
|
||||
} catch (e) {
|
||||
console.error(`Failed to load language file for ${lang}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
await loadLanguage('en');
|
||||
|
||||
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
|
||||
if (locale && locale !== 'en') {
|
||||
await loadLanguage(locale);
|
||||
|
||||
if (i18n.mode === 'legacy') {
|
||||
i18n.global.locale = locale;
|
||||
} else {
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
const app = createApp(LogsViewer);
|
||||
|
||||
app.use(i18n);
|
||||
app.use(await i18n());
|
||||
|
||||
app.mount('#app');
|
||||
})();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import superagent from 'superagent';
|
||||
import { ISTATES } from '../constants.js';
|
||||
import { fetcher } from 'pankow';
|
||||
import { sleep } from 'pankow/utils';
|
||||
|
||||
export function create(origin, accessToken, id) {
|
||||
@@ -9,13 +9,13 @@ export function create(origin, accessToken, id) {
|
||||
async get() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
|
||||
result = await fetcher.get(`${origin}/api/v1/apps/${id}`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 200) {
|
||||
console.error(`Invalid app ${id}`, error || result.statusCode);
|
||||
if (error || result.status !== 200) {
|
||||
console.error(`Invalid app ${id}`, error || result.status);
|
||||
this.fatalError = `Invalid app ${id}`;
|
||||
return;
|
||||
}
|
||||
@@ -25,25 +25,25 @@ export function create(origin, accessToken, id) {
|
||||
async restart() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.post(`${origin}/api/v1/apps/${id}/restart`).query({ access_token: accessToken });
|
||||
result = await fetcher.post(`${origin}/api/v1/apps/${id}/restart`, null, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 202) {
|
||||
console.error(`Failed to restart app ${this.id}`, error || result.statusCode);
|
||||
if (error || result.status !== 202) {
|
||||
console.error(`Failed to restart app ${this.id}`, error || result.status);
|
||||
return;
|
||||
}
|
||||
|
||||
while(true) {
|
||||
let result;
|
||||
try {
|
||||
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
|
||||
result = await fetcher.get(`${origin}/api/v1/apps/${id}`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
|
||||
if (result && result.status === 200 && result.body.installationState === ISTATES.INSTALLED) break;
|
||||
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import superagent from 'superagent';
|
||||
import { fetcher } from 'pankow';
|
||||
import { sanitize } from 'pankow/utils';
|
||||
|
||||
const BASE_URL = import.meta.env.BASE_URL || '/';
|
||||
@@ -35,15 +35,15 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
async listFiles(path) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${origin}/api/v1/${api}/files/${path}`).query({ access_token: accessToken });
|
||||
result = await fetcher.get(`${origin}/api/v1/${api}/files/${path}`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 200) {
|
||||
if (error || result.status !== 200) {
|
||||
if (error.status === 404) return [];
|
||||
|
||||
console.error('Failed to list files', error || result.statusCode);
|
||||
console.error('Failed to list files', error || result.status);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
// if we have an image, attach previewUrl
|
||||
if (item.mimeType.indexOf('image/') === 0) {
|
||||
item.previewUrl = `${origin}/api/v1/${api}/files/${encodeURIComponent(path + '/' + item.fileName)}?access_token=${accessToken}`;
|
||||
} else {
|
||||
item.previewUrl = '';
|
||||
}
|
||||
|
||||
item.owner = item.uid;
|
||||
@@ -67,13 +69,12 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
|
||||
return result.body.entries;
|
||||
},
|
||||
async upload(targetDir, file, progressHandler) {
|
||||
upload(targetDir, file, progressHandler) {
|
||||
// file may contain a file name or a file path + file name
|
||||
const relativefilePath = (file.webkitRelativePath ? file.webkitRelativePath : file.name);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
const req = new Promise(function (resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.response);
|
||||
@@ -94,7 +95,66 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
if (event.loaded) progressHandler({ direction: 'upload', loaded: event.loaded});
|
||||
});
|
||||
|
||||
xhr.open('POST', `${origin}/api/v1/${api}/files/${encodeURIComponent(sanitize(targetDir + '/' + relativefilePath))}?access_token=${accessToken}`);
|
||||
xhr.open('POST', `${origin}/api/v1/${api}/files/${encodeURIComponent(sanitize(targetDir + '/' + relativefilePath))}?access_token=${accessToken}&overwrite=true`);
|
||||
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
// attach for upstream xhr.abort()
|
||||
req.xhr = xhr;
|
||||
|
||||
return req;
|
||||
},
|
||||
async newFile(filePath) {
|
||||
await this.save(filePath, '');
|
||||
},
|
||||
async newFolder(folderPath) {
|
||||
await fetcher.post(`${origin}/api/v1/${api}/files/${folderPath}`, { access_token: accessToken, directory: true });
|
||||
},
|
||||
async remove(filePath) {
|
||||
await fetcher.del(`${origin}/api/v1/${api}/files/${filePath}`, { access_token: accessToken });
|
||||
},
|
||||
async rename(fromFilePath, toFilePath, overwrite = false) {
|
||||
await fetcher.put(`${origin}/api/v1/${api}/files/${fromFilePath}`, { action: 'rename', newFilePath: sanitize(toFilePath), overwrite }, { access_token: accessToken });
|
||||
},
|
||||
async copy(fromFilePath, toFilePath) {
|
||||
await fetcher.put(`${origin}/api/v1/${api}/files/${fromFilePath}`, { action: 'copy', newFilePath: sanitize(toFilePath) }, { access_token: accessToken });
|
||||
},
|
||||
async chown(filePath, uid) {
|
||||
await fetcher.put(`${origin}/api/v1/${api}/files/${filePath}`, { action: 'chown', uid: uid, recursive: true }, { access_token: accessToken });
|
||||
},
|
||||
async extract(path) {
|
||||
await fetcher.put(`${origin}/api/v1/${api}/files/${path}`, { action: 'extract' }, { access_token: accessToken });
|
||||
},
|
||||
async download(path) {
|
||||
window.open(`${origin}/api/v1/${api}/files/${path}?download=true&access_token=${accessToken}`);
|
||||
},
|
||||
async save(filePath, content) {
|
||||
const file = new File([content], 'file');
|
||||
|
||||
const req = new Promise(function (resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.response);
|
||||
} else {
|
||||
reject({
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText
|
||||
});
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('error', () => {
|
||||
reject({
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText
|
||||
});
|
||||
});
|
||||
|
||||
xhr.open('POST', `${origin}/api/v1/${api}/files/${filePath}?access_token=${accessToken}&overwrite=true`);
|
||||
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
xhr.setRequestHeader('Content-Length', file.size);
|
||||
@@ -102,49 +162,7 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
const res = await req;
|
||||
},
|
||||
async newFile(folderPath, fileName) {
|
||||
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}`)
|
||||
.query({ access_token: accessToken })
|
||||
.attach('file', new File([], fileName));
|
||||
},
|
||||
async newFolder(folderPath) {
|
||||
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}?access_token=${accessToken}&directory=true`);
|
||||
},
|
||||
async remove(filePath) {
|
||||
await superagent.del(`${origin}/api/v1/${api}/files/${filePath}`)
|
||||
.query({ access_token: accessToken });
|
||||
},
|
||||
async rename(fromFilePath, toFilePath, overwrite = false) {
|
||||
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
|
||||
.send({ action: 'rename', newFilePath: sanitize(toFilePath), overwrite })
|
||||
.query({ access_token: accessToken });
|
||||
},
|
||||
async copy(fromFilePath, toFilePath) {
|
||||
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
|
||||
.send({ action: 'copy', newFilePath: sanitize(toFilePath) })
|
||||
.query({ access_token: accessToken });
|
||||
},
|
||||
async chown(filePath, uid) {
|
||||
await superagent.put(`${origin}/api/v1/${api}/files/${filePath}`)
|
||||
.send({ action: 'chown', uid: uid, recursive: true })
|
||||
.query({ access_token: accessToken });
|
||||
},
|
||||
async extract(path) {
|
||||
await superagent.put(`${origin}/api/v1/${api}/files/${path}`)
|
||||
.send({ action: 'extract' })
|
||||
.query({ access_token: accessToken });
|
||||
},
|
||||
async download(path) {
|
||||
window.open(`${origin}/api/v1/${api}/files/${path}?download=true&access_token=${accessToken}`);
|
||||
},
|
||||
async save(filePath, content) {
|
||||
const file = new File([content], 'file');
|
||||
await superagent.post(`${origin}/api/v1/${api}/files/${filePath}`)
|
||||
.query({ access_token: accessToken })
|
||||
.attach('file', file)
|
||||
.field('overwrite', 'true');
|
||||
await req;
|
||||
},
|
||||
async getFile(path) {
|
||||
let result;
|
||||
@@ -160,7 +178,7 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
},
|
||||
async paste(targetDir, action, files) {
|
||||
// this will not overwrite but tries to find a new unique name to past to
|
||||
for (let f in files) {
|
||||
for (const f in files) {
|
||||
let done = false;
|
||||
let targetPath = targetDir + '/' + files[f].name;
|
||||
while (!done) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
|
||||
import moment from 'moment';
|
||||
import superagent from 'superagent';
|
||||
import { ansiToHtml } from 'anser';
|
||||
import { ISTATES } from '../constants.js';
|
||||
import { sleep } from 'pankow/utils';
|
||||
|
||||
// https://github.com/janl/mustache.js/blob/master/mustache.js#L60
|
||||
const entityMap = {
|
||||
@@ -58,7 +55,17 @@ export function create(origin, accessToken, type, id) {
|
||||
name: 'LogsModel',
|
||||
stream(lineHandler, errorHandler) {
|
||||
eventSource = new EventSource(`${origin}${streamApi}?lines=${INITIAL_STREAM_LINES}&access_token=${accessToken}`);
|
||||
eventSource.onerror = errorHandler;
|
||||
eventSource._lastMessage = null;
|
||||
eventSource.onerror = function ( /* uselessError */) {
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
// eventSource does not give us the HTTP error code. We have to resort to message count check and guess the reason
|
||||
const msg = eventSource._lastMessage === null ? `Logs unavailable. Maybe the logs were logrotated.` : `Connection closed.`;
|
||||
const e = new Error(msg);
|
||||
e.time = moment().format('MMM DD HH:mm:ss');
|
||||
e.html = ansiToHtml(e.message);
|
||||
errorHandler(e);
|
||||
}
|
||||
};
|
||||
// eventSource.onopen = function () { console.log('stream is open'); };
|
||||
eventSource.onmessage = function (message) {
|
||||
var data;
|
||||
@@ -72,56 +79,12 @@ export function create(origin, accessToken, type, id) {
|
||||
const time = data.realtimeTimestamp ? moment(data.realtimeTimestamp/1000).format('MMM DD HH:mm:ss') : '';
|
||||
const html = ansiToHtml(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message)));
|
||||
|
||||
eventSource._lastMessage = { time, html };
|
||||
lineHandler(time, html);
|
||||
};
|
||||
},
|
||||
getDownloadUrl() {
|
||||
return `${origin}${downloadApi}?access_token=${accessToken}&format=short&lines=-1`;
|
||||
},
|
||||
// TODO maybe move this into AppsModel.js
|
||||
async getApp() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 200) {
|
||||
console.error(`Invalid app ${id}`, error || result.statusCode);
|
||||
this.fatalError = `Invalid app ${id}`;
|
||||
return;
|
||||
}
|
||||
|
||||
return result.body;
|
||||
},
|
||||
async restartApp() {
|
||||
if (type !== 'app') return;
|
||||
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.post(`${origin}/api/v1/apps/${id}/restart`).query({ access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 202) {
|
||||
console.error(`Failed to restart app ${this.id}`, error || result.statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
while(true) {
|
||||
let result;
|
||||
try {
|
||||
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
|
||||
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,19 +9,6 @@ html, body {
|
||||
color: var(--pankow-text-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 300 !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #2196f3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover, a:focus {
|
||||
color: #0a6ebd;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,.1);
|
||||
}
|
||||
@@ -29,9 +16,3 @@ a:hover, a:focus {
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,16 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import './style.css';
|
||||
|
||||
import '@fontsource/noto-sans';
|
||||
|
||||
import superagent from 'superagent';
|
||||
|
||||
import i18n from './i18n.js';
|
||||
import Terminal from './components/Terminal.vue';
|
||||
|
||||
const translations = {};
|
||||
const i18n = createI18n({
|
||||
locale: 'en', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
messages: translations,
|
||||
// will replace our double {{}} to vue-i18n single brackets
|
||||
messageResolver: (keys, key) => {
|
||||
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
|
||||
|
||||
// fallback tr key
|
||||
if (message === null) message = key;
|
||||
|
||||
return message.replaceAll('{{', '{').replaceAll('}}', '}');
|
||||
}
|
||||
});
|
||||
|
||||
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
|
||||
(async function loadLanguages() {
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
|
||||
|
||||
async function loadLanguage(lang) {
|
||||
try {
|
||||
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
|
||||
|
||||
// we do not deliver as application/json :/
|
||||
translations[lang] = JSON.parse(result.text);
|
||||
} catch (e) {
|
||||
console.error(`Failed to load language file for ${lang}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
await loadLanguage('en');
|
||||
|
||||
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
|
||||
if (locale && locale !== 'en') {
|
||||
await loadLanguage(locale);
|
||||
|
||||
if (i18n.mode === 'legacy') {
|
||||
i18n.global.locale = locale;
|
||||
} else {
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
const app = createApp(Terminal);
|
||||
|
||||
app.use(i18n);
|
||||
app.use(await i18n());
|
||||
|
||||
app.mount('#app');
|
||||
})();
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<template #header>
|
||||
<TopBar class="navbar">
|
||||
<template #left>
|
||||
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain/>
|
||||
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain tool/>
|
||||
<Breadcrumb :home="breadcrumbHomeItem" :items="breadcrumbItems" :activate-handler="onActivateBreadcrumb"/>
|
||||
</template>
|
||||
<template #right>
|
||||
@@ -37,9 +37,9 @@
|
||||
<Button icon="fa-solid fa-upload" @click="onUploadMenu">{{ $t('filemanager.toolbar.upload') }}</Button>
|
||||
<Menu ref="uploadMenu" :model="uploadMenuModel"/>
|
||||
|
||||
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
|
||||
<Button :href="'/frontend/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
|
||||
<Button :href="'/frontend/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary icon="fa-solid fa-align-left" :title="$t('logs.title')" />
|
||||
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
|
||||
<Button :href="'/frontend/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
|
||||
<Button :href="'/frontend/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-align-left" :title="$t('logs.title')" />
|
||||
</template>
|
||||
</TopBar>
|
||||
</template>
|
||||
@@ -57,8 +57,6 @@
|
||||
:delete-handler="deleteHandler"
|
||||
:rename-handler="renameHandler"
|
||||
:change-owner-handler="changeOwnerHandler"
|
||||
:copy-handler="copyHandler"
|
||||
:cut-handler="cutHandler"
|
||||
:paste-handler="pasteHandler"
|
||||
:download-handler="downloadHandler"
|
||||
:extract-handler="extractHandler"
|
||||
@@ -68,7 +66,6 @@
|
||||
:upload-folder-handler="onUploadFolder"
|
||||
:drop-handler="onDrop"
|
||||
:items="items"
|
||||
:clipboard="clipboard"
|
||||
:owners-model="ownersModel"
|
||||
:fallback-icon="fallbackIcon"
|
||||
:tr="$t"
|
||||
@@ -100,10 +97,9 @@
|
||||
|
||||
<script>
|
||||
|
||||
import superagent from 'superagent';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { Dialog, DirectoryView, TopBar, Breadcrumb, BottomBar, Button, InputDialog, MainLayout, Menu, FileUploader, Spinner } from 'pankow';
|
||||
import { fetcher, Dialog, DirectoryView, TopBar, Breadcrumb, BottomBar, Button, InputDialog, MainLayout, Menu, FileUploader, Spinner } from 'pankow';
|
||||
import Icon from 'pankow/components/Icon.vue';
|
||||
import { sanitize, sleep } from 'pankow/utils';
|
||||
|
||||
@@ -112,7 +108,7 @@ import { ISTATES } from '../constants.js';
|
||||
import PreviewPanel from '../components/PreviewPanel.vue';
|
||||
import { createDirectoryModel } from '../models/DirectoryModel.js';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
|
||||
const BASE_URL = import.meta.env.BASE_URL || '/';
|
||||
|
||||
const beforeUnloadListener = (event) => {
|
||||
@@ -150,12 +146,7 @@ export default {
|
||||
activeDirectoryItem: {},
|
||||
items: [],
|
||||
selectedItems: [],
|
||||
clipboard: {
|
||||
action: '', // copy or cut
|
||||
files: []
|
||||
},
|
||||
accessToken: localStorage.token,
|
||||
apiOrigin: API_ORIGIN || '',
|
||||
title: 'Cloudron',
|
||||
appLink: '',
|
||||
resourceType: '',
|
||||
@@ -213,6 +204,76 @@ export default {
|
||||
this.loadCwd();
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.busy = true;
|
||||
const type = this.$route.params.type || 'app';
|
||||
const resourceId = this.$route.params.resourceId;
|
||||
const cwd = this.$route.params.cwd;
|
||||
|
||||
if (type === 'app') {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${resourceId}`, { access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) {
|
||||
console.error(`Invalid resource ${type} ${resourceId}`, error || result.status);
|
||||
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
|
||||
}
|
||||
|
||||
this.appLink = `https://${result.body.fqdn}`;
|
||||
this.title = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
|
||||
} else if (type === 'volume') {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/volumes/${resourceId}`, { access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) {
|
||||
console.error(`Invalid resource ${type} ${resourceId}`, error || result.status);
|
||||
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
|
||||
}
|
||||
|
||||
this.title = result.body.name;
|
||||
} else {
|
||||
return this.onFatalError(`Unsupported type ${type}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetcher.get(`${API_ORIGIN}/api/v1/dashboard/config`, { access_token: this.accessToken });
|
||||
this.footerContent = marked.parse(result.body.footer);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch Cloudron config.', e);
|
||||
}
|
||||
|
||||
window.document.title = `File Manager - ${this.title}`;
|
||||
|
||||
this.cwd = sanitize('/' + (cwd ? cwd.join('/') : '/'));
|
||||
this.resourceType = type;
|
||||
this.resourceId = resourceId;
|
||||
|
||||
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
|
||||
this.ownersModel = this.directoryModel.ownersModel;
|
||||
|
||||
this.loadCwd();
|
||||
|
||||
this.$watch(() => this.$route.params, (toParams, previousParams) => {
|
||||
if (toParams.type !== 'app' && toParams.type !== 'volume') return this.onFatalError(`Unknown type ${toParams.type}`);
|
||||
|
||||
if ((toParams.type !== this.resourceType) || (toParams.resourceId !== this.resourceId)) {
|
||||
this.resourceType = toParams.type;
|
||||
this.resourceId = toParams.resourceId;
|
||||
|
||||
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
|
||||
}
|
||||
|
||||
this.cwd = toParams.cwd ? `/${toParams.cwd.join('/')}` : '/';
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
onFatalError(errorMessage) {
|
||||
this.fatalError = errorMessage;
|
||||
@@ -225,8 +286,8 @@ export default {
|
||||
this.$refs.uploadMenu.open(event, elem);
|
||||
},
|
||||
onCancelUpload() {
|
||||
if (!this.uploadRequest) return;
|
||||
this.uploadRequest.abort();
|
||||
if (!this.uploadRequest || !this.uploadRequest.xhr) return;
|
||||
this.uploadRequest.xhr.abort();
|
||||
},
|
||||
// generic dialog focus handler
|
||||
onDialogShow(focusElementId) {
|
||||
@@ -244,7 +305,7 @@ export default {
|
||||
|
||||
if (!newFileName) return;
|
||||
|
||||
await this.directoryModel.newFile(this.directoryModel.buildFilePath(this.cwd, newFileName), newFileName);
|
||||
await this.directoryModel.newFile(this.directoryModel.buildFilePath(this.cwd, newFileName));
|
||||
await this.loadCwd();
|
||||
},
|
||||
async onNewFolder() {
|
||||
@@ -292,36 +353,50 @@ export default {
|
||||
async onDrop(targetFolder, dataTransfer, files) {
|
||||
const fullTargetFolder = sanitize(this.cwd + '/' + targetFolder);
|
||||
|
||||
// if dataTransfer is set, we have a file/folder drop from outside
|
||||
if (dataTransfer) {
|
||||
// figure if a folder was dropped on a modern browser, in this case the first would have to be a directory
|
||||
let folderItem;
|
||||
try {
|
||||
folderItem = dataTransfer.items[0].webkitGetAsEntry();
|
||||
if (folderItem.isFile) return this.$refs.fileUploader.addFiles(dataTransfer.files, fullTargetFolder, false);
|
||||
} catch (e) {
|
||||
return this.$refs.fileUploader.addFiles(dataTransfer.files, fullTargetFolder, false);
|
||||
|
||||
async function getFile(entry) {
|
||||
return new Promise((resolve, reject) => {
|
||||
entry.file(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
// if we got here we have a folder drop and a modern browser
|
||||
// now traverse the folder tree and create a file list
|
||||
var that = this;
|
||||
function traverseFileTree(item, path) {
|
||||
async function readEntries(dirReader) {
|
||||
return new Promise((resolve, reject) => {
|
||||
dirReader.readEntries(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
const fileList = [];
|
||||
async function traverseFileTree(item) {
|
||||
if (item.isFile) {
|
||||
item.file(function (file) {
|
||||
that.$refs.fileUploader.addFiles([file], sanitize(`${that.cwd}/${targetFolder}`), false);
|
||||
});
|
||||
fileList.push(await getFile(item));
|
||||
} else if (item.isDirectory) {
|
||||
// Get folder contents
|
||||
var dirReader = item.createReader();
|
||||
dirReader.readEntries(function (entries) {
|
||||
for (let i in entries) {
|
||||
traverseFileTree(entries[i], item.name);
|
||||
}
|
||||
});
|
||||
const dirReader = item.createReader();
|
||||
const entries = await readEntries(dirReader);
|
||||
|
||||
for (let i in entries) {
|
||||
await traverseFileTree(entries[i], item.name);
|
||||
}
|
||||
} else {
|
||||
console.log('Skipping uknown file type', item);
|
||||
}
|
||||
}
|
||||
|
||||
traverseFileTree(folderItem, '');
|
||||
// collect all files to upload
|
||||
for (const item of dataTransfer.items) {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
|
||||
if (entry.isFile) {
|
||||
fileList.push(await getFile(entry));
|
||||
} else if (entry.isDirectory) {
|
||||
await traverseFileTree(entry, sanitize(`${this.cwd}/${targetFolder}`));
|
||||
}
|
||||
}
|
||||
|
||||
this.$refs.fileUploader.addFiles(fileList, sanitize(`${this.cwd}/${targetFolder}`));
|
||||
} else {
|
||||
if (!files.length) return;
|
||||
|
||||
@@ -346,16 +421,8 @@ export default {
|
||||
async deleteHandler(files) {
|
||||
if (!files) return;
|
||||
|
||||
function start_and_end(str) {
|
||||
if (str.length > 100) {
|
||||
return str.substr(0, 45) + ' ... ' + str.substr(str.length-45, str.length);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
const confirmed = await this.$refs.inputDialog.confirm({
|
||||
message: this.$t('filemanager.removeDialog.reallyDelete'),
|
||||
// message: start_and_end(files.map((f) => f.name).join(', ')),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: this.$t('main.dialog.yes'),
|
||||
rejectLabel: this.$t('main.dialog.no'),
|
||||
@@ -412,31 +479,15 @@ export default {
|
||||
|
||||
await this.loadCwd();
|
||||
},
|
||||
async copyHandler(files) {
|
||||
if (!files) return;
|
||||
|
||||
this.clipboard = {
|
||||
action: 'copy',
|
||||
files
|
||||
};
|
||||
},
|
||||
async cutHandler(files) {
|
||||
if (!files) return;
|
||||
|
||||
this.clipboard = {
|
||||
action: 'cut',
|
||||
files
|
||||
};
|
||||
},
|
||||
async pasteHandler(target) {
|
||||
if (!this.clipboard.files || !this.clipboard.files.length) return;
|
||||
async pasteHandler(action, files, target) {
|
||||
if (!files || !files.length) return;
|
||||
|
||||
const targetPath = (target && target.isDirectory) ? sanitize(this.cwd + '/' + target.fileName) : this.cwd;
|
||||
|
||||
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
|
||||
this.$refs.pasteInProgressDialog.open();
|
||||
|
||||
await this.directoryModel.paste(targetPath, this.clipboard.action, this.clipboard.files);
|
||||
await this.directoryModel.paste(targetPath, action, files);
|
||||
this.clipboard = {};
|
||||
await this.loadCwd();
|
||||
|
||||
@@ -458,7 +509,7 @@ export default {
|
||||
try {
|
||||
await this.uploadRequest;
|
||||
} catch (e) {
|
||||
console.log('Upload cancelled.');
|
||||
console.log('Upload cancelled.', e);
|
||||
}
|
||||
|
||||
this.uploadRequest = null;
|
||||
@@ -489,101 +540,31 @@ export default {
|
||||
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.resourceId}/restart`).query({ access_token: this.accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${this.resourceId}/restart`, null, { access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 202) {
|
||||
console.error(`Failed to restart app ${this.resourceId}`, error || result.statusCode);
|
||||
if (error || result.status !== 202) {
|
||||
console.error(`Failed to restart app ${this.resourceId}`, error || result.status);
|
||||
return;
|
||||
}
|
||||
|
||||
while(true) {
|
||||
let error, result;
|
||||
let result;
|
||||
try {
|
||||
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${this.resourceId}`).query({ access_token: this.accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${this.resourceId}`, { access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
console.error('Failed to fetch app status.', e);
|
||||
}
|
||||
|
||||
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
|
||||
if (result && result.status === 200 && result.body.installationState === ISTATES.INSTALLED) break;
|
||||
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
this.busyRestart = false;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.busy = true;
|
||||
const type = this.$route.params.type || 'app';
|
||||
const resourceId = this.$route.params.resourceId;
|
||||
const cwd = this.$route.params.cwd;
|
||||
|
||||
if (type === 'app') {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${resourceId}`).query({ access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 200) {
|
||||
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
|
||||
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
|
||||
}
|
||||
|
||||
this.appLink = `https://${result.body.fqdn}`;
|
||||
this.title = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
|
||||
} else if (type === 'volume') {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${this.apiOrigin}/api/v1/volumes/${resourceId}`).query({ access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 200) {
|
||||
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
|
||||
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
|
||||
}
|
||||
|
||||
this.title = result.body.name;
|
||||
} else {
|
||||
return this.onFatalError(`Unsupported type ${type}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await superagent.get(`${this.apiOrigin}/api/v1/dashboard/config`).query({ access_token: this.accessToken });
|
||||
this.footerContent = marked.parse(result.body.footer);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch Cloudron config.', e);
|
||||
}
|
||||
|
||||
window.document.title = `File Manager - ${this.title}`;
|
||||
|
||||
this.cwd = sanitize('/' + (cwd ? cwd.join('/') : '/'));
|
||||
this.resourceType = type;
|
||||
this.resourceId = resourceId;
|
||||
|
||||
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
|
||||
this.ownersModel = this.directoryModel.ownersModel;
|
||||
|
||||
this.loadCwd();
|
||||
|
||||
this.$watch(() => this.$route.params, (toParams, previousParams) => {
|
||||
if (toParams.type !== 'app' && toParams.type !== 'volume') return this.onFatalError(`Unknown type ${toParams.type}`);
|
||||
|
||||
if ((toParams.type !== this.resourceType) || (toParams.resourceId !== this.resourceId)) {
|
||||
this.resourceType = toParams.type;
|
||||
this.resourceId = toParams.resourceId;
|
||||
|
||||
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
|
||||
}
|
||||
|
||||
this.cwd = toParams.cwd ? `/${toParams.cwd.join('/')}` : '/';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { TextViewer, ImageViewer } from 'pankow-viewers';
|
||||
import { createDirectoryModel } from '../models/DirectoryModel.js';
|
||||
import { sanitize } from 'pankow/utils';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
|
||||
|
||||
export default {
|
||||
name: 'Viewer',
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -37,7 +37,6 @@
|
||||
"multiparty": "^4.2.3",
|
||||
"mysql": "^2.18.1",
|
||||
"nodemailer": "^6.9.13",
|
||||
"nsyslog-parser-2": "^0.9.11",
|
||||
"oidc-provider": "^8.4.6",
|
||||
"ovh": "^2.0.3",
|
||||
"qrcode": "^1.5.3",
|
||||
@@ -4215,11 +4214,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/nsyslog-parser-2": {
|
||||
"version": "0.9.11",
|
||||
"resolved": "https://registry.npmjs.org/nsyslog-parser-2/-/nsyslog-parser-2-0.9.11.tgz",
|
||||
"integrity": "sha512-QF13YP12BAA38NOWescMjiEoyJtnRV5k++fYOP8kNqKFtCubv1w73W9UhjCeER4l87M+4CWlm3MJcD5ZbgDJAg=="
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.10",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz",
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"multiparty": "^4.2.3",
|
||||
"mysql": "^2.18.1",
|
||||
"nodemailer": "^6.9.13",
|
||||
"nsyslog-parser-2": "^0.9.11",
|
||||
"oidc-provider": "^8.4.6",
|
||||
"ovh": "^2.0.3",
|
||||
"qrcode": "^1.5.3",
|
||||
|
||||
@@ -19,16 +19,18 @@ readonly HELP_MESSAGE="
|
||||
Cloudron Support and Diagnostics Tool
|
||||
|
||||
Options:
|
||||
--disable-dnssec Disable DNSSEC
|
||||
--enable-remote-access Enable SSH Remote Access for the Cloudron support team
|
||||
--patch Apply a patch from git. WARNING: Do not use unless you know what you are doing!
|
||||
--recreate-containers Deletes all existing containers and recreates them without loss of data
|
||||
--recreate-docker Deletes docker storage (containers and images) and recreates it without loss of data
|
||||
--send-diagnostics Collects server diagnostics and uploads it to ${PASTEBIN}
|
||||
--troubleshoot Dashboard down? Run tests to identify the potential problem
|
||||
--owner-login Login as owner
|
||||
--use-external-dns Forwards all DNS requests to Google (8.8.8.8) and Cloudflare (1.1.1.1) DNS servers
|
||||
--help Show this message
|
||||
--disable-dnssec Disable DNSSEC
|
||||
--enable-remote-access Enable SSH Remote Access for the Cloudron support team
|
||||
--patch Apply a patch from git. WARNING: Do not use unless you know what you are doing!
|
||||
--recreate-containers Deletes all existing containers and recreates them without loss of data
|
||||
--recreate-docker Deletes docker storage (containers and images) and recreates it without loss of data
|
||||
--send-diagnostics Collects server diagnostics and uploads it to ${PASTEBIN}
|
||||
--troubleshoot Dashboard down? Run tests to identify the potential problem
|
||||
--owner-login Login as owner
|
||||
--unbound-use-external-dns Forwards all Unbound requests to Google (8.8.8.8) and Cloudflare (1.1.1.1) DNS servers.
|
||||
Unbound is the internal DNS server used for recursive DNS queries. This is only needed
|
||||
if your network does not allow outbound DNS requests.
|
||||
--help Show this message
|
||||
"
|
||||
|
||||
function success() {
|
||||
@@ -133,11 +135,10 @@ function check_netplan() {
|
||||
fi
|
||||
|
||||
if [[ -z "${output}" ]]; then
|
||||
fail "netplan configuration is empty"
|
||||
exit 1
|
||||
warn "netplan configuration is empty. this might be OK depending on your networking setup"
|
||||
else
|
||||
success "netplan is good"
|
||||
fi
|
||||
|
||||
success "netplan is good"
|
||||
}
|
||||
|
||||
function owner_login() {
|
||||
@@ -217,13 +218,31 @@ function send_diagnostics() {
|
||||
}
|
||||
|
||||
function check_dns() {
|
||||
if ! host cloudron.io &>/dev/null; then
|
||||
fail "DNS is not resolving"
|
||||
host cloudron.io
|
||||
exit 1
|
||||
if host cloudron.io &>/dev/null; then
|
||||
success "DNS is resolving via systemd-resolved"
|
||||
return
|
||||
fi
|
||||
|
||||
success "DNS is resolving via systemd-resolved"
|
||||
if ! systemctl is-active -q systemd-resolved; then
|
||||
warn "systemd-resolved is not in use. see 'systemctl status systemd-resolved'"
|
||||
fi
|
||||
|
||||
if [[ -L /etc/resolv.conf ]]; then
|
||||
target=$(readlink /etc/resolv.conf)
|
||||
if [[ "$target" != *"/run/systemd/resolve/stub-resolv.conf" ]]; then
|
||||
warn "/etc/resolv.conf is symlinked to $target instead of '../run/systemd/resolve/stub-resolv.conf'"
|
||||
fi
|
||||
else
|
||||
warn "/etc/resolv.conf is not symlinked to '../run/systemd/resolve/stub-resolv.conf'"
|
||||
fi
|
||||
|
||||
if ! grep -q "^nameserver 127.0.0.53" /etc/resolv.conf; then
|
||||
warn "/etc/resolv.conf is not using systemd-resolved. it is missing the line 'nameserver 127.0.0.53'"
|
||||
fi
|
||||
|
||||
fail "DNS is not resolving"
|
||||
host cloudron.io || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
function check_unbound() {
|
||||
@@ -244,7 +263,7 @@ function check_unbound() {
|
||||
fi
|
||||
|
||||
if ! host cloudron.io 127.0.0.150 &>/dev/null; then
|
||||
fail "Unbound is not resolving, maybe try forwarding all DNS requests. You can do this by running 'cloudron-support --use-external-dns' option"
|
||||
fail "Unbound is not resolving, maybe try forwarding all DNS requests. You can do this by running 'cloudron-support --unbound-use-external-dns' option"
|
||||
host cloudron.io 127.0.0.150
|
||||
exit 1
|
||||
fi
|
||||
@@ -342,6 +361,16 @@ function check_node() {
|
||||
success "node version is correct"
|
||||
}
|
||||
|
||||
function check_ipv6() {
|
||||
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
|
||||
if [[ "${ipv6_disable}" == "1" ]]; then
|
||||
fail "IPv6 is disabled in kernel. Cloudron requires IPv6 in kernel"
|
||||
echo "Instead of disabling IPv6 globally, you can disable it at an interface level using 'net.ipv6.conf.<interface>.disable_ipv6 = 1'"
|
||||
fi
|
||||
|
||||
success "IPv6 is enabled"
|
||||
}
|
||||
|
||||
function check_docker() {
|
||||
if ! systemctl is-active -q docker; then
|
||||
info "Docker is down. Trying to restart docker ..."
|
||||
@@ -430,7 +459,7 @@ function check_expired_domain() {
|
||||
success "Domain ${dashboard_domain} is valid and has not expired"
|
||||
}
|
||||
|
||||
function use_external_dns() {
|
||||
function unbount_use_external_dns() {
|
||||
local -r conf_file="/etc/unbound/unbound.conf.d/forward-everything.conf"
|
||||
|
||||
info "To remove the forwarding, please delete $conf_file and 'systemctl restart unbound'"
|
||||
@@ -484,6 +513,7 @@ function troubleshoot() {
|
||||
# note: disk space test has already been run globally
|
||||
print_system
|
||||
check_node
|
||||
check_ipv6
|
||||
check_docker
|
||||
check_host_mysql
|
||||
check_nginx # requires mysql to be checked
|
||||
@@ -572,6 +602,7 @@ function ask_reboot() {
|
||||
function recreate_docker() {
|
||||
readonly logfile="/home/yellowtent/platformdata/logs/box.log"
|
||||
readonly stagefile="/home/yellowtent/platformdata/recreate-docker-stage"
|
||||
readonly containerd_root="/var/lib/containerd"
|
||||
|
||||
if ! docker_root=$(docker info -f '{{ .DockerRootDir }}' 2>/dev/null); then
|
||||
warning "Unable to detect docker root. Assuming /var/lib/docker"
|
||||
@@ -597,7 +628,7 @@ function recreate_docker() {
|
||||
|
||||
if grep -q "clearing_storage" "${stagefile}"; then
|
||||
info "Clearing docker storage at ${docker_root}"
|
||||
if ! rm -rf "${docker_root}/"*; then
|
||||
if ! rm -rf "${docker_root}/"* "${containerd_root}/"*; then
|
||||
echo -e "\nThe server has to be rebooted to clear the docker storage. After reboot,"
|
||||
echo -e "run 'cloudron-support --recreate-docker' again.\n"
|
||||
ask_reboot
|
||||
@@ -658,7 +689,7 @@ function apply_patch() {
|
||||
|
||||
check_disk_space
|
||||
|
||||
args=$(getopt -o "" -l "admin-login,disable-dnssec,enable-ssh,enable-remote-access,help,owner-login,patch:,recreate-containers,recreate-docker,send-diagnostics,use-external-dns,troubleshoot" -n "$0" -- "$@")
|
||||
args=$(getopt -o "" -l "admin-login,disable-dnssec,enable-ssh,enable-remote-access,help,owner-login,patch:,recreate-containers,recreate-docker,send-diagnostics,unbound-use-external-dns,troubleshoot" -n "$0" -- "$@")
|
||||
eval set -- "${args}"
|
||||
|
||||
while true; do
|
||||
@@ -674,7 +705,7 @@ while true; do
|
||||
--send-diagnostics) send_diagnostics; exit 0;;
|
||||
--troubleshoot) troubleshoot; exit 0;;
|
||||
--disable-dnssec) disable_dnssec; exit 0;;
|
||||
--use-external-dns) use_external_dns; exit 0;;
|
||||
--unbound-use-external-dns) unbound_use_external_dns; exit 0;;
|
||||
--recreate-containers) recreate_containers; exit 0;;
|
||||
--recreate-docker) recreate_docker; exit 0;;
|
||||
--patch) apply_patch "$2"; exit 0;;
|
||||
|
||||
@@ -120,6 +120,7 @@ apt-get -o Dpkg::Options::="--force-confold" install -y --no-install-recommends
|
||||
# this ensures that unattended upgades are enabled, if it was disabled during ubuntu install time (see #346)
|
||||
# debconf-set-selection of unattended-upgrades/enable_auto_updates + dpkg-reconfigure does not work
|
||||
# logs of upgrades are at /var/log/apt/history.log and /var/log/unattended-upgrades/unattended-upgrades-dpkg.log
|
||||
# apt-daily-upgrade.service (timer) runs the unattended-upgrades script depending on APT::Periodic::Unattended-Upgrade
|
||||
echo "==> Enabling automatic upgrades"
|
||||
cp /usr/share/unattended-upgrades/20auto-upgrades /etc/apt/apt.conf.d/20auto-upgrades
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ readonly MAIL_DATA_DIR="${HOME_DIR}/boxdata/mail"
|
||||
readonly script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly ubuntu_version=$(lsb_release -rs)
|
||||
|
||||
vergte() {
|
||||
greater_version=$(echo -e "$1\n$2" | sort -rV | head -n1)
|
||||
[[ "$1" == "${greater_version}" ]] && return 0 || return 1
|
||||
}
|
||||
|
||||
cp -f "${script_dir}/../scripts/cloudron-support" /usr/bin/cloudron-support
|
||||
cp -f "${script_dir}/../scripts/cloudron-translation-update" /usr/bin/cloudron-translation-update
|
||||
rm -f /usr/bin/cloudron-logs # legacy script
|
||||
@@ -109,7 +114,11 @@ systemctl restart systemd-journald
|
||||
usermod -a -G adm ${USER}
|
||||
|
||||
log "Setting up unbound"
|
||||
cp -f "${script_dir}/start/unbound.conf" /etc/unbound/unbound.conf.d/cloudron-network.conf
|
||||
cp -f "${script_dir}/start/unbound/unbound.conf" /etc/unbound/unbound.conf.d/cloudron-network.conf
|
||||
unbound_version=$(unbound -V | sed -n 's/^Version \([0-9.]*\)/\1/p')
|
||||
if vergte "${unbound_version}" "1.19.2"; then
|
||||
cp "${script_dir}/start/unbound/prefer-ip4.conf" /etc/unbound/unbound.conf.d/cloudron-prefer-ip4.conf
|
||||
fi
|
||||
rm -f /etc/unbound/unbound.conf.d/remote-control.conf # on ubuntu 24
|
||||
# update the root anchor after a out-of-disk-space situation (see #269)
|
||||
# it returns 1 even on fail, it's not clear - https://unbound.docs.nlnetlabs.nl/en/latest/manpages/unbound-anchor.html#exit-code
|
||||
|
||||
@@ -8,6 +8,7 @@ Wants=network-online.target nss-lookup.target
|
||||
|
||||
[Service]
|
||||
PIDFile=/run/unbound.pid
|
||||
ExecStartPre=/usr/sbin/unbound-anchor -a /var/lib/unbound/root.key
|
||||
ExecStart=/usr/sbin/unbound -d
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
|
||||
6
setup/start/unbound/prefer-ip4.conf
Normal file
6
setup/start/unbound/prefer-ip4.conf
Normal file
@@ -0,0 +1,6 @@
|
||||
# Prefer IPv4 outbound queries. Spamhaus often rejects queries from IPv6 addresses
|
||||
# This setting is in a separate file since it only works from Ubuntu 24 , unbound 1.19.2
|
||||
|
||||
server:
|
||||
prefer-ip4: yes
|
||||
|
||||
@@ -2108,7 +2108,7 @@ async function getLogs(app, options) {
|
||||
const appId = app.id;
|
||||
|
||||
const logPaths = await getLogPaths(app);
|
||||
const cp = logs.tail(logPaths, { lines: options.lines, follow: options.follow });
|
||||
const cp = logs.tail(logPaths, { lines: options.lines, follow: options.follow, sudo: true }); // need sudo access for paths inside app container (manifest.logPaths)
|
||||
|
||||
const logStream = new logs.LogStream({ format: options.format || 'json', source: appId });
|
||||
logStream.on('close', () => cp.terminate()); // the caller has to call destroy() on logStream. destroy() of Transform emits 'close'
|
||||
@@ -2628,8 +2628,9 @@ async function autoupdateApps(updateInfo, auditSource) { // updateInfo is { appI
|
||||
force: false
|
||||
};
|
||||
|
||||
debug(`app ${app.fqdn} will be automatically updated`);
|
||||
const [updateError] = await safe(updateApp(app, data, auditSource));
|
||||
if (updateError) debug(`Error initiating autoupdate of ${appId}. ${updateError.message}`);
|
||||
if (updateError) debug(`Error autoupdating ${appId}. ${updateError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,9 +243,10 @@ async function downloadImage(manifest) {
|
||||
await docker.downloadImage(manifest);
|
||||
}
|
||||
|
||||
async function updateChecklist(app, newChecks) {
|
||||
async function updateChecklist(app, newChecks, acknowledged = false) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
assert.strictEqual(typeof newChecks, 'object');
|
||||
assert.strictEqual(typeof acknowledged, 'boolean');
|
||||
|
||||
// add new checklist items depending on sso state
|
||||
const checklist = app.checklist || {};
|
||||
@@ -253,7 +254,7 @@ async function updateChecklist(app, newChecks) {
|
||||
if (app.checklist[k]) continue;
|
||||
|
||||
const item = {
|
||||
acknowledged: false,
|
||||
acknowledged: acknowledged,
|
||||
sso: newChecks[k].sso,
|
||||
appVersion: app.version,
|
||||
message: newChecks[k].message,
|
||||
@@ -296,7 +297,7 @@ async function install(app, args, progressCallback) {
|
||||
await verifyManifest(app.manifest);
|
||||
|
||||
// teardown for re-installs
|
||||
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 10, message: 'Deleting old containers' });
|
||||
await reverseProxy.unconfigureApp(app);
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
|
||||
@@ -324,7 +325,7 @@ async function install(app, args, progressCallback) {
|
||||
await downloadIcon(app);
|
||||
|
||||
await progressCallback({ percent: 25, message: 'Updating checklist' });
|
||||
await updateChecklist(app, app.manifest.checklist || {});
|
||||
await updateChecklist(app, app.manifest.checklist || {}, restoreConfig ? true : false);
|
||||
|
||||
if (!skipDnsSetup) {
|
||||
await progressCallback({ percent: 30, message: 'Registering subdomains' });
|
||||
@@ -406,7 +407,7 @@ async function create(app, args, progressCallback) {
|
||||
assert.strictEqual(typeof args, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 10, message: 'Deleting old container' });
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
|
||||
// FIXME: re-setup addons only because sendmail addon to re-inject env vars on mailboxName change
|
||||
@@ -432,7 +433,7 @@ async function changeLocation(app, args, progressCallback) {
|
||||
const skipDnsSetup = args.skipDnsSetup;
|
||||
const overwriteDns = args.overwriteDns;
|
||||
|
||||
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 10, message: 'Unregistering old domains' });
|
||||
await reverseProxy.unconfigureApp(app);
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
|
||||
@@ -493,7 +494,7 @@ async function changeServices(app, args, progressCallback) {
|
||||
assert.strictEqual(typeof args, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 10, message: 'Deleting old containers' });
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
|
||||
const unusedAddons = {};
|
||||
@@ -526,7 +527,7 @@ async function migrateDataDir(app, args, progressCallback) {
|
||||
assert(newStorageVolumeId === null || typeof newStorageVolumeId === 'string');
|
||||
assert(newStorageVolumePrefix === null || typeof newStorageVolumePrefix === 'string');
|
||||
|
||||
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 10, message: 'Deleting old containers' });
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
|
||||
await progressCallback({ percent: 45, message: 'Ensuring app data directory' });
|
||||
@@ -556,7 +557,7 @@ async function configure(app, args, progressCallback) {
|
||||
assert.strictEqual(typeof args, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 10, message: 'Deleting old containers' });
|
||||
await reverseProxy.unconfigureApp(app);
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
|
||||
@@ -616,7 +617,7 @@ async function update(app, args, progressCallback) {
|
||||
}
|
||||
|
||||
await progressCallback({ percent: 20, message: 'Updating checklist' });
|
||||
await updateChecklist(app, app.manifest.checklist || {});
|
||||
await updateChecklist(app, app.manifest.checklist || {}, true /* new state acked */);
|
||||
|
||||
// download new image before app is stopped. this is so we can reduce downtime
|
||||
// and also not remove the 'common' layers when the old image is deleted
|
||||
@@ -625,7 +626,7 @@ async function update(app, args, progressCallback) {
|
||||
|
||||
// note: we cleanup first and then backup. this is done so that the app is not running should backup fail
|
||||
// we cannot easily 'recover' from backup failures because we have to revert manfest and portBindings
|
||||
await progressCallback({ percent: 35, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 35, message: 'Deleting old containers' });
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
if (app.manifest.dockerImage !== updateConfig.manifest.dockerImage) await docker.deleteImage(app.manifest);
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ function applyBackupRetention(allBackups, retention, referencedBackupIds) {
|
||||
}
|
||||
|
||||
if (retention.keepLatest) {
|
||||
let latestNormalBackup = allBackups.find(b => b.state === backups.BACKUP_STATE_NORMAL);
|
||||
const latestNormalBackup = allBackups.find(b => b.state === backups.BACKUP_STATE_NORMAL);
|
||||
if (latestNormalBackup && !latestNormalBackup.keepReason) latestNormalBackup.keepReason = 'latest';
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ async function cleanupAppBackups(backupConfig, retention, referencedBackupIds, p
|
||||
const appBackups = await backups.getByTypePaged(backups.BACKUP_TYPE_APP, 1, 1000);
|
||||
|
||||
// collate the backups by app id. note that the app could already have been uninstalled
|
||||
let appBackupsById = {};
|
||||
const appBackupsById = {};
|
||||
for (const appBackup of appBackups) {
|
||||
if (!appBackupsById[appBackup.identifier]) appBackupsById[appBackup.identifier] = [];
|
||||
appBackupsById[appBackup.identifier].push(appBackup);
|
||||
@@ -177,7 +177,8 @@ async function cleanupBoxBackups(backupConfig, retention, progressCallback) {
|
||||
assert.strictEqual(typeof retention, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
let referencedBackupIds = [], removedBoxBackupPaths = [];
|
||||
let referencedBackupIds = [];
|
||||
const removedBoxBackupPaths = [];
|
||||
|
||||
const boxBackups = await backups.getByTypePaged(backups.BACKUP_TYPE_BOX, 1, 1000);
|
||||
|
||||
@@ -200,7 +201,7 @@ async function cleanupBoxBackups(backupConfig, retention, progressCallback) {
|
||||
return { removedBoxBackupPaths, referencedBackupIds };
|
||||
}
|
||||
|
||||
// cleans up the database by checking if backup exists in the remote
|
||||
// cleans up the database by checking if backup exists in the remote. this can happen if user had set some bucket policy
|
||||
async function cleanupMissingBackups(backupConfig, progressCallback) {
|
||||
assert.strictEqual(typeof backupConfig, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
@@ -215,6 +216,8 @@ async function cleanupMissingBackups(backupConfig, progressCallback) {
|
||||
result = await backups.list(page, perPage);
|
||||
|
||||
for (const backup of result) {
|
||||
if (backup.state !== backups.BACKUP_STATE_NORMAL) continue; // note: errored and incomplete backups are cleaned up by the backup retention logic
|
||||
|
||||
let backupFilePath = backupFormat.api(backup.format).getBackupFilePath(backupConfig, backup.remotePath);
|
||||
if (backup.format === 'rsync') backupFilePath = backupFilePath + '/'; // add trailing slash to indicate directory
|
||||
|
||||
@@ -276,7 +279,7 @@ async function run(progressCallback) {
|
||||
const { retention } = await backups.getPolicy();
|
||||
debug(`run: retention is ${JSON.stringify(retention)}`);
|
||||
|
||||
const status = await storage.api(backupConfig.provider).getProviderStatus(backupConfig);
|
||||
const status = await backups.ensureMounted();
|
||||
debug(`run: mount point status is ${JSON.stringify(status)}`);
|
||||
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not mounted: ${status.message}`);
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ exports = module.exports = {
|
||||
|
||||
remount,
|
||||
getMountStatus,
|
||||
ensureMounted,
|
||||
|
||||
BACKUP_IDENTIFIER_BOX: 'box',
|
||||
BACKUP_IDENTIFIER_MAIL: 'mail',
|
||||
@@ -359,9 +360,7 @@ function managedBackupMountObject(backupConfig) {
|
||||
};
|
||||
}
|
||||
|
||||
async function remount(auditSource) {
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
|
||||
async function remount() {
|
||||
const backupConfig = await getConfig();
|
||||
|
||||
if (mounts.isManagedProvider(backupConfig.provider)) {
|
||||
@@ -380,12 +379,20 @@ async function getMountStatus() {
|
||||
} else if (backupConfig.provider === 'filesystem') {
|
||||
hostPath = backupConfig.backupFolder;
|
||||
} else {
|
||||
throw new BoxError(BoxError.BAD_STATE, 'Backup location is not a mount');
|
||||
return { state: 'active' };
|
||||
}
|
||||
|
||||
return await mounts.getStatus(backupConfig.provider, hostPath); // { state, message }
|
||||
}
|
||||
|
||||
async function ensureMounted() {
|
||||
const status = await getMountStatus();
|
||||
if (status.state === 'active') return status;
|
||||
|
||||
await remount();
|
||||
return await getMountStatus();
|
||||
}
|
||||
|
||||
async function getPolicy() {
|
||||
const result = await settings.getJson(settings.BACKUP_POLICY_KEY);
|
||||
return result || {
|
||||
|
||||
@@ -52,7 +52,7 @@ async function checkPreconditions(backupConfig, dataLayout) {
|
||||
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
|
||||
|
||||
// check mount status before uploading
|
||||
const status = await storage.api(backupConfig.provider).getProviderStatus(backupConfig);
|
||||
const status = await backups.ensureMounted();
|
||||
debug(`upload: mount point status is ${JSON.stringify(status)}`);
|
||||
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not active: ${status.message}`);
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ async function handleAutoupdatePatternChanged(pattern) {
|
||||
const updateInfo = updateChecker.getUpdateInfo();
|
||||
// do box before app updates. for the off chance that the box logic fixes some app update logic issue
|
||||
if (updateInfo.box && !updateInfo.box.unstable) {
|
||||
debug('Starting box autoupdate to %j', updateInfo.box);
|
||||
debug('Starting box autoupdate to %j', updateInfo.box.version);
|
||||
const [error] = await safe(updater.updateToLatest({ skipBackup: false }, AuditSource.CRON));
|
||||
if (!error) return; // do not start app updates when a box update got scheduled
|
||||
debug(`Failed to start box autoupdate task: ${error.message}`);
|
||||
@@ -232,7 +232,7 @@ async function handleAutoupdatePatternChanged(pattern) {
|
||||
|
||||
const appUpdateInfo = _.omit(updateInfo, 'box');
|
||||
if (Object.keys(appUpdateInfo).length > 0) {
|
||||
debug('Starting app update to %j', appUpdateInfo);
|
||||
debug('Starting app autoupdate: %j', Object.keys(appUpdateInfo));
|
||||
const [error] = await safe(apps.autoupdateApps(appUpdateInfo, AuditSource.CRON));
|
||||
if (error) debug(`Failed to app autoupdate: ${error.message}`);
|
||||
} else {
|
||||
|
||||
@@ -355,7 +355,7 @@ async function createSubcontainer(app, name, cmd, options) {
|
||||
},
|
||||
// CpuPeriod (100000 microseconds) and CpuQuota(app.cpuQuota% of CpuPeriod)
|
||||
// 1000000000 is one core https://github.com/moby/moby/issues/24713#issuecomment-233167619 and https://stackoverflow.com/questions/52391877/set-the-number-of-cpu-cores-of-a-container-using-docker-engine-api
|
||||
NanoCPUs: app.cpuQuota === 100 ? 0 : (os.cpus().length * app.cpuQuota/100).toFixed(2) * 1000000000,
|
||||
NanoCPUs: app.cpuQuota === 100 ? 0 : Math.round(os.cpus().length * app.cpuQuota/100 * 1000000000),
|
||||
VolumesFrom: isAppContainer ? null : [ app.containerId + ':rw' ],
|
||||
SecurityOpt: [ 'apparmor=docker-cloudron-app' ],
|
||||
CapAdd: [],
|
||||
@@ -373,8 +373,7 @@ async function createSubcontainer(app, name, cmd, options) {
|
||||
if (isAppContainer) {
|
||||
containerOptions.Hostname = app.id;
|
||||
containerOptions.HostConfig.NetworkMode = 'cloudron'; // user defined bridge network
|
||||
containerOptions.HostConfig.Dns = ['172.18.0.1']; // use internal dns
|
||||
containerOptions.HostConfig.DnsSearch = ['.']; // use internal dns
|
||||
containerOptions.HostConfig.ExtraHosts = [ `${dashboardFqdn}:172.18.0.1` ];
|
||||
|
||||
containerOptions.NetworkingConfig = {
|
||||
EndpointsConfig: {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
exports = module.exports = {
|
||||
// a version change recreates all containers with latest docker config
|
||||
'version': '49.7.0',
|
||||
'version': '49.8.0',
|
||||
|
||||
// a major version bump in the db containers will trigger the restore logic that uses the db dumps
|
||||
// docker inspect --format='{{index .RepoDigests 0}}' $IMAGE to get the sha256
|
||||
@@ -18,7 +18,7 @@ exports = module.exports = {
|
||||
'mysql': 'registry.docker.com/cloudron/mysql:3.4.3@sha256:8934c5ddcd69f24740d9a38f0de2937e47240238f3b8f5c482862eeccc5a21d2',
|
||||
'postgresql': 'registry.docker.com/cloudron/postgresql:5.2.3@sha256:9b7d5147e9c8008e4766cc80ebf4b833f3dfcf19ef0d81b013dfab76995d8d16',
|
||||
'redis': 'registry.docker.com/cloudron/redis:3.5.3@sha256:1e1200900c6fb196950531ecec43f400b4fe5e559fac1c75f21e6f0c11885b5f',
|
||||
'sftp': 'registry.docker.com/cloudron/sftp:3.8.7@sha256:9d13007f665d72875e7e4830fc4d5ee352c7c1a44b4f0f526746e2c2d2c296e0',
|
||||
'sftp': 'registry.docker.com/cloudron/sftp:3.8.9@sha256:f2a126839df99ca420a3ad8177594f58b113f6292e98719f2cf2e0ddc3597696',
|
||||
'turn': 'registry.docker.com/cloudron/turn:1.7.2@sha256:9ed8da613c1edc5cb8700657cf6e49f0f285b446222a8f459f80919945352f6d',
|
||||
}
|
||||
};
|
||||
|
||||
15
src/logs.js
15
src/logs.js
@@ -57,10 +57,17 @@ function tail(filePaths, options) {
|
||||
assert.strictEqual(typeof options, 'object');
|
||||
|
||||
const lines = options.lines === -1 ? '+1' : options.lines;
|
||||
const args = [ LOGTAIL_CMD, '--lines=' + lines ];
|
||||
const args = options.sudo ? [ LOGTAIL_CMD ] : [];
|
||||
args.push(`--lines=${lines}`);
|
||||
if (options.follow) args.push('--follow');
|
||||
|
||||
return shell.sudo('tail', args.concat(filePaths), { streamStdout: true }, () => {});
|
||||
if (options.sudo) {
|
||||
return shell.sudo('tail', args.concat(filePaths), { quiet: true }, () => {});
|
||||
} else {
|
||||
const cp = spawn('/usr/bin/tail', args.concat(filePaths));
|
||||
cp.terminate = () => cp.kill('SIGKILL');
|
||||
return cp;
|
||||
}
|
||||
}
|
||||
|
||||
function journalctl(unit, options) {
|
||||
@@ -76,7 +83,9 @@ function journalctl(unit, options) {
|
||||
|
||||
if (options.follow) args.push('--follow');
|
||||
|
||||
return spawn('journalctl', args);
|
||||
const cp = spawn('journalctl', args);
|
||||
cp.terminate = () => cp.kill('SIGKILL');
|
||||
return cp;
|
||||
}
|
||||
|
||||
exports = module.exports = {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
exports = module.exports = {
|
||||
cookieParser: require('cookie-parser'),
|
||||
cors: require('./cors.js'),
|
||||
proxy: require('./proxy-middleware.js'),
|
||||
lastMile: require('connect-lastmile'),
|
||||
multipart: require('./multipart.js'),
|
||||
timeout: require('connect-timeout')
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
// https://github.com/cloudron-io/node-proxy-middleware
|
||||
// MIT license
|
||||
// contains https://github.com/gonzalocasas/node-proxy-middleware/pull/59
|
||||
|
||||
var os = require('os');
|
||||
var http = require('http');
|
||||
var https = require('https');
|
||||
var owns = {}.hasOwnProperty;
|
||||
|
||||
module.exports = function proxyMiddleware(options) {
|
||||
//enable ability to quickly pass a url for shorthand setup
|
||||
if(typeof options === 'string'){
|
||||
options = require('url').parse(options);
|
||||
}
|
||||
|
||||
var httpLib = options.protocol === 'https:' ? https : http;
|
||||
var request = httpLib.request;
|
||||
|
||||
options = options || {};
|
||||
options.hostname = options.hostname;
|
||||
options.port = options.port;
|
||||
options.pathname = options.pathname || '/';
|
||||
|
||||
return function (req, resp, next) {
|
||||
var url = req.url;
|
||||
// You can pass the route within the options, as well
|
||||
if (typeof options.route === 'string') {
|
||||
if (url === options.route) {
|
||||
url = '';
|
||||
} else if (url.slice(0, options.route.length) === options.route) {
|
||||
url = url.slice(options.route.length);
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
//options for this request
|
||||
var opts = extend({}, options);
|
||||
if (url && url.charAt(0) === '?') { // prevent /api/resource/?offset=0
|
||||
if (options.pathname.length > 1 && options.pathname.charAt(options.pathname.length - 1) === '/') {
|
||||
opts.path = options.pathname.substring(0, options.pathname.length - 1) + url;
|
||||
} else {
|
||||
opts.path = options.pathname + url;
|
||||
}
|
||||
} else if (url) {
|
||||
opts.path = slashJoin(options.pathname, url);
|
||||
} else {
|
||||
opts.path = options.pathname;
|
||||
}
|
||||
opts.method = req.method;
|
||||
opts.headers = options.headers ? merge(req.headers, options.headers) : req.headers;
|
||||
|
||||
applyViaHeader(req.headers, opts, opts.headers);
|
||||
|
||||
if (!options.preserveHost) {
|
||||
// Forwarding the host breaks dotcloud
|
||||
delete opts.headers.host;
|
||||
}
|
||||
|
||||
var myReq = request(opts, function (myRes) {
|
||||
var statusCode = myRes.statusCode
|
||||
, headers = myRes.headers
|
||||
, location = headers.location;
|
||||
// Fix the location
|
||||
if (((statusCode > 300 && statusCode < 304) || statusCode === 201) && location && location.indexOf(options.href) > -1) {
|
||||
// absoulte path
|
||||
headers.location = location.replace(options.href, slashJoin('/', slashJoin((options.route || ''), '')));
|
||||
}
|
||||
applyViaHeader(myRes.headers, opts, myRes.headers);
|
||||
rewriteCookieHosts(myRes.headers, opts, myRes.headers, req);
|
||||
resp.writeHead(myRes.statusCode, myRes.headers);
|
||||
myRes.on('error', function (err) {
|
||||
next(err);
|
||||
});
|
||||
myRes.on('end', function (err) {
|
||||
next();
|
||||
});
|
||||
myRes.pipe(resp);
|
||||
});
|
||||
myReq.on('error', function (err) {
|
||||
next(err);
|
||||
});
|
||||
if (!req.readable) {
|
||||
myReq.end();
|
||||
} else {
|
||||
req.pipe(myReq);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
function applyViaHeader(existingHeaders, opts, applyTo) {
|
||||
if (!opts.via) return;
|
||||
|
||||
var viaName = (true === opts.via) ? os.hostname() : opts.via;
|
||||
var viaHeader = '1.1 ' + viaName;
|
||||
if(existingHeaders.via) {
|
||||
viaHeader = existingHeaders.via + ', ' + viaHeader;
|
||||
}
|
||||
|
||||
applyTo.via = viaHeader;
|
||||
}
|
||||
|
||||
function rewriteCookieHosts(existingHeaders, opts, applyTo, req) {
|
||||
if (!opts.cookieRewrite || !owns.call(existingHeaders, 'set-cookie')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var existingCookies = existingHeaders['set-cookie'],
|
||||
rewrittenCookies = [],
|
||||
rewriteHostname = (true === opts.cookieRewrite) ? os.hostname() : opts.cookieRewrite;
|
||||
|
||||
if (!Array.isArray(existingCookies)) {
|
||||
existingCookies = [ existingCookies ];
|
||||
}
|
||||
|
||||
for (var i = 0; i < existingCookies.length; i++) {
|
||||
var rewrittenCookie = existingCookies[i].replace(/(Domain)=[a-z\.-_]*?(;|$)/gi, '$1=' + rewriteHostname + '$2');
|
||||
|
||||
if (!req.connection.encrypted) {
|
||||
rewrittenCookie = rewrittenCookie.replace(/;\s*?(Secure)/i, '');
|
||||
}
|
||||
rewrittenCookies.push(rewrittenCookie);
|
||||
}
|
||||
|
||||
applyTo['set-cookie'] = rewrittenCookies;
|
||||
}
|
||||
|
||||
function slashJoin(p1, p2) {
|
||||
var trailing_slash = false;
|
||||
|
||||
if (p1.length && p1[p1.length - 1] === '/') { trailing_slash = true; }
|
||||
if (trailing_slash && p2.length && p2[0] === '/') {p2 = p2.substring(1); }
|
||||
|
||||
return p1 + p2;
|
||||
}
|
||||
|
||||
function extend(obj, src) {
|
||||
for (var key in src) if (owns.call(src, key)) obj[key] = src[key];
|
||||
return obj;
|
||||
}
|
||||
|
||||
//merges data without changing state in either argument
|
||||
function merge(src1, src2) {
|
||||
var merged = {};
|
||||
extend(merged, src1);
|
||||
extend(merged, src2);
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function validateMountOptions(type, options) {
|
||||
}
|
||||
}
|
||||
|
||||
// managed providers are those for which we setup systemd mount file
|
||||
// managed providers are those for which we setup systemd mount file under /mnt/volumes
|
||||
function isManagedProvider(provider) {
|
||||
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'ext4' || provider === 'xfs' || provider === 'disk';
|
||||
}
|
||||
@@ -151,7 +151,7 @@ async function getStatus(mountType, hostPath) {
|
||||
|
||||
if (mountType === 'filesystem') return { state: 'active', message: 'Mounted' };
|
||||
|
||||
const [error] = await safe(shell.execArgs('getVolumeStatus', 'mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 }));
|
||||
const [error] = await safe(shell.execArgs('getStatus', 'mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 }));
|
||||
const state = error ? 'inactive' : 'active';
|
||||
|
||||
if (mountType === 'mountpoint') return { state, message: state === 'active' ? 'Mounted' : 'Not mounted' };
|
||||
|
||||
@@ -288,6 +288,10 @@ server {
|
||||
location ~ ^/translation/ {
|
||||
root <%= sourceDir %>/dashboard/dist;
|
||||
add_header "Access-Control-Allow-Origin" "*";
|
||||
|
||||
types {
|
||||
application/json json;
|
||||
}
|
||||
}
|
||||
|
||||
# Cross domain webfont access for proxy auth login page https://github.com/h5bp/server-configs/issues/85
|
||||
|
||||
@@ -18,6 +18,7 @@ exports = module.exports = {
|
||||
INFRA_VERSION_FILE: path.join(baseDir(), 'platformdata/INFRA_VERSION'),
|
||||
CRON_SEED_FILE: path.join(baseDir(), 'platformdata/CRON_SEED'),
|
||||
DASHBOARD_DIR: constants.TEST ? path.join(__dirname, '../dashboard/src') : path.join(baseDir(), 'box/dashboard/dist'),
|
||||
TRANSLATIONS_DIR: constants.TEST ? path.join(__dirname, '../dashboard/src/translation') : path.join(baseDir(), 'box/dashboard/dist/translation'),
|
||||
|
||||
PROVIDER_FILE: '/etc/cloudron/PROVIDER',
|
||||
SETUP_TOKEN_FILE: '/etc/cloudron/SETUP_TOKEN',
|
||||
|
||||
@@ -266,9 +266,9 @@ async function onDashboardLocationSet(subdomain, domain) {
|
||||
async function onDashboardLocationChanged(auditSource) {
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
|
||||
// mark apps using oidc addon to be reconfigured
|
||||
// mark all apps to be reconfigured, all have ExtraHosts injected
|
||||
const [, installedApps] = await safe(apps.list());
|
||||
await safe(apps.configureApps(installedApps.filter((a) => !!a.manifest.addons?.oidc), { scheduleNow: true }, auditSource), { debug });
|
||||
await safe(apps.configureApps(installedApps, { scheduleNow: true }, auditSource), { debug });
|
||||
|
||||
await safe(services.rebuildService('turn', auditSource), { debug }); // to update the realm variable
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ async function cleanup(req, res, next) {
|
||||
}
|
||||
|
||||
async function remount(req, res, next) {
|
||||
const [error] = await safe(backups.remount(AuditSource.fromRequest(req)));
|
||||
const [error] = await safe(backups.remount());
|
||||
if (error) return next(BoxError.toHttpError(error));
|
||||
|
||||
next(new HttpSuccess(202, {}));
|
||||
|
||||
@@ -6,11 +6,10 @@ exports = module.exports = {
|
||||
|
||||
const assert = require('assert'),
|
||||
BoxError = require('../boxerror.js'),
|
||||
middleware = require('../middleware/index.js'),
|
||||
http = require('http'),
|
||||
HttpError = require('connect-lastmile').HttpError,
|
||||
safe = require('safetydance'),
|
||||
services = require('../services.js'),
|
||||
url = require('url');
|
||||
services = require('../services.js');
|
||||
|
||||
function proxy(kind) {
|
||||
assert(kind === 'mail' || kind === 'volume' || kind === 'app');
|
||||
@@ -25,25 +24,33 @@ function proxy(kind) {
|
||||
case 'mail': id = 'mail'; break;
|
||||
}
|
||||
|
||||
const [error, result] = await safe(services.getContainerDetails('sftp', 'CLOUDRON_SFTP_TOKEN'));
|
||||
const [error, addonDetails] = await safe(services.getContainerDetails('sftp', 'CLOUDRON_SFTP_TOKEN'));
|
||||
if (error) return next(BoxError.toHttpError(error));
|
||||
|
||||
const parsedUrl = url.parse(req.url, true /* parseQueryString */);
|
||||
parsedUrl.query['access_token'] = result.token;
|
||||
const searchParams = new URLSearchParams(req.url.slice(req.url.indexOf('?')+1));
|
||||
searchParams.delete('access_token');
|
||||
searchParams.append('access_token', addonDetails.token);
|
||||
|
||||
req.url = url.format({ pathname: `/files/${id}/${encodeURIComponent(req.params[0])}`, query: parsedUrl.query }); // params[0] already contains leading '/'
|
||||
const opts = {
|
||||
hostname: addonDetails.ip,
|
||||
port: 3000,
|
||||
path: `/files/${id}/${encodeURIComponent(req.params[0])}?${searchParams.toString()}`, // params[0] already contains leading '/'
|
||||
method: req.method,
|
||||
headers: req.headers
|
||||
};
|
||||
|
||||
const proxyOptions = url.parse(`http://${result.ip}:3000`);
|
||||
proxyOptions.rejectUnauthorized = false;
|
||||
const fileManagerProxy = middleware.proxy(proxyOptions);
|
||||
|
||||
fileManagerProxy(req, res, function (error) {
|
||||
if (!error) return next();
|
||||
|
||||
if (error.code === 'ECONNREFUSED') return next(new HttpError(424, 'Unable to connect to filemanager server'));
|
||||
if (error.code === 'ECONNRESET') return next(new HttpError(424, 'Unable to query filemanager server'));
|
||||
|
||||
next(new HttpError(500, error));
|
||||
});
|
||||
};
|
||||
const sftpReq = http.request(opts, function (sftpRes) {
|
||||
res.writeHead(sftpRes.statusCode, sftpRes.headers);
|
||||
// note: these are intentionally not handled. response has already been written. do not forward to connect-lastmile
|
||||
// sftpRes.on('error', (error) => next(new HttpError(500, `filemanager error: ${error.message} ${error.code}`)));
|
||||
// sftpRes.on('end', () => next());
|
||||
sftpRes.pipe(res);
|
||||
});
|
||||
sftpReq.on('error', (error) => next(new HttpError(424, `Unable to connect to filemanager: ${error.message} ${error.code}`)));
|
||||
if (!req.readable) {
|
||||
sftpReq.end();
|
||||
} else {
|
||||
req.pipe(sftpReq);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,40 +13,43 @@ const assert = require('assert'),
|
||||
AuditSource = require('../auditsource.js'),
|
||||
BoxError = require('../boxerror.js'),
|
||||
debug = require('debug')('box:routes/mailserver'),
|
||||
http = require('http'),
|
||||
HttpError = require('connect-lastmile').HttpError,
|
||||
HttpSuccess = require('connect-lastmile').HttpSuccess,
|
||||
mailServer = require('../mailserver.js'),
|
||||
middleware = require('../middleware/index.js'),
|
||||
safe = require('safetydance'),
|
||||
services = require('../services.js'),
|
||||
url = require('url');
|
||||
services = require('../services.js');
|
||||
|
||||
async function proxyToMailContainer(port, pathname, req, res, next) {
|
||||
const parsedUrl = url.parse(req.url, true /* parseQueryString */);
|
||||
|
||||
// do not proxy protected values
|
||||
delete parsedUrl.query['access_token'];
|
||||
delete req.headers['authorization'];
|
||||
delete req.headers['cookies'];
|
||||
req.clearTimeout();
|
||||
|
||||
const [error, addonDetails] = await safe(services.getContainerDetails('mail', 'CLOUDRON_MAIL_TOKEN'));
|
||||
if (error) return next(BoxError.toHttpError(error));
|
||||
|
||||
parsedUrl.query['access_token'] = addonDetails.token;
|
||||
req.url = url.format({ pathname, query: parsedUrl.query });
|
||||
const searchParams = new URLSearchParams(req.url.slice(req.url.indexOf('?')+1));
|
||||
searchParams.delete('access_token');
|
||||
searchParams.append('access_token', addonDetails.token);
|
||||
|
||||
const proxyOptions = url.parse(`http://${addonDetails.ip}:${port}`);
|
||||
const mailserverProxy = middleware.proxy(proxyOptions);
|
||||
const opts = {
|
||||
hostname: addonDetails.ip,
|
||||
port: 3000,
|
||||
path: `/${pathname}?${searchParams.toString()}`,
|
||||
method: req.method,
|
||||
headers: req.headers
|
||||
};
|
||||
|
||||
req.clearTimeout(); // TODO: add timeout to mail server proxy logic instead of this
|
||||
mailserverProxy(req, res, function (error) {
|
||||
if (!error) return next(); // note: response was already sent by proxy by this point
|
||||
|
||||
if (error.code === 'ECONNREFUSED') return next(new HttpError(424, 'Unable to connect to mail server'));
|
||||
if (error.code === 'ECONNRESET') return next(new HttpError(424, 'Unable to query mail server'));
|
||||
|
||||
next(new HttpError(500, error));
|
||||
const sftpReq = http.request(opts, function (sftpRes) {
|
||||
res.writeHead(sftpRes.statusCode, sftpRes.headers);
|
||||
sftpRes.on('error', (error) => next(new HttpError(500, `mailserver error: ${error.message} ${error.code}`)));
|
||||
sftpRes.on('end', () => next());
|
||||
sftpRes.pipe(res);
|
||||
});
|
||||
sftpReq.on('error', (error) => next(new HttpError(424, `Unable to connect to mailserver: ${error.message} ${error.code}`)));
|
||||
if (!req.readable) {
|
||||
sftpReq.end();
|
||||
} else {
|
||||
req.pipe(sftpReq);
|
||||
}
|
||||
}
|
||||
|
||||
async function proxy(req, res, next) {
|
||||
|
||||
@@ -50,7 +50,16 @@ async function providerTokenAuth(req, res, next) {
|
||||
if (system.getProvider() === 'ami') {
|
||||
if (typeof req.body.providerToken !== 'string' || !req.body.providerToken) return next(new HttpError(400, 'providerToken must be a non empty string'));
|
||||
|
||||
const [error, response] = await safe(superagent.get('http://169.254.169.254/latest/meta-data/instance-id').timeout(30 * 1000).ok(() => true));
|
||||
|
||||
// IMDSv2 https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-options.html
|
||||
// https://aws.amazon.com/blogs/security/defense-in-depth-open-firewalls-reverse-proxies-ssrf-vulnerabilities-ec2-instance-metadata-service/
|
||||
const imdsIp = req.body.ipv4Config?.provider === 'noop' ? '[fd00:ec2::254]' : '169.254.169.254'; // use ipv4config carefully, it's not validated yet at this point
|
||||
const [tokenError, tokenResponse] = await safe(superagent.put(`http://${imdsIp}/latest/api/token`).set('x-aws-ec2-metadata-token-ttl-seconds', 600).timeout(30 * 1000).ok(() => true));
|
||||
if (tokenError) return next(new HttpError(422, `Network error getting EC2 metadata session token: ${tokenError.message}`));
|
||||
if (tokenResponse.status !== 200) return next(new HttpError(422, `Unable to get EC2 meta data session token. statusCode: ${tokenResponse.status}`));
|
||||
const imdsToken = tokenResponse.text;
|
||||
|
||||
const [error, response] = await safe(superagent.get(`http://${imdsIp}/latest/meta-data/instance-id`).set('x-aws-ec2-metadata-token', imdsToken).timeout(30 * 1000).ok(() => true));
|
||||
if (error) return next(new HttpError(422, `Network error getting EC2 metadata: ${error.message}`));
|
||||
if (response.status !== 200) return next(new HttpError(422, `Unable to get EC2 meta data. statusCode: ${response.status}`));
|
||||
if (response.text !== req.body.providerToken) return next(new HttpError(422, 'Instance ID does not match'));
|
||||
|
||||
@@ -29,6 +29,6 @@ while true; do
|
||||
done
|
||||
|
||||
# first sort the existing log lines
|
||||
tail --quiet --lines=${lines} -- "$@" | sort -k1 || true # ignore error if files are missing
|
||||
tail --quiet --lines=${lines} "$@" | sort -k1 || true # ignore error if files are missing
|
||||
|
||||
exec tail ${follow} --lines=0 "$@"
|
||||
|
||||
@@ -23,7 +23,7 @@ host_path="$1"
|
||||
mount_filename=$(systemd-escape -p --suffix=mount "$host_path")
|
||||
mount_file="/etc/systemd/system/${mount_filename}"
|
||||
|
||||
# stop and start will do the reumount
|
||||
# stop and start will do the remount
|
||||
systemctl stop "${mount_filename}"
|
||||
systemctl start "${mount_filename}"
|
||||
|
||||
|
||||
@@ -113,23 +113,24 @@ async function initializeExpressSync() {
|
||||
router.post('/api/v1/dashboard/location', json, token, authorizeAdmin, routes.dashboard.changeLocation);
|
||||
|
||||
// system (vm/server)
|
||||
router.get ('/api/v1/system/info', token, authorizeAdmin, routes.system.getInfo);
|
||||
router.get ('/api/v1/system/info', token, authorizeAdmin, routes.system.getInfo); // vendor, product name etc
|
||||
router.post('/api/v1/system/reboot', json, token, authorizeAdmin, routes.system.reboot);
|
||||
router.get ('/api/v1/system/graphs', token, authorizeAdmin, routes.system.getSystemGraphs);
|
||||
router.get ('/api/v1/system/cpus', token, authorizeAdmin, routes.system.getCpus);
|
||||
router.get ('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.getDiskUsage);
|
||||
router.post('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.updateDiskUsage);
|
||||
router.get ('/api/v1/system/block_devices', token, authorizeAdmin, routes.system.getBlockDevices);
|
||||
router.get ('/api/v1/system/memory', token, authorizeAdmin, routes.system.getMemory);
|
||||
router.get ('/api/v1/system/logs/:unit', token, authorizeAdmin, routes.system.getLogs);
|
||||
router.get ('/api/v1/system/logstream/:unit', token, authorizeAdmin, routes.system.getLogStream);
|
||||
// app operators require cpu and memory info for the Resources UI
|
||||
router.get ('/api/v1/system/cpus', token, authorizeUser, routes.system.getCpus);
|
||||
router.get ('/api/v1/system/memory', token, authorizeUser, routes.system.getMemory);
|
||||
|
||||
// eventlog
|
||||
router.get ('/api/v1/eventlog', token, authorizeAdmin, routes.eventlog.list);
|
||||
router.get ('/api/v1/eventlog/:eventId', token, authorizeAdmin, routes.eventlog.get);
|
||||
|
||||
// updater
|
||||
router.get ('/api/v1/updater/updates', token, authorizeAdmin, routes.updater.getUpdateInfo);
|
||||
router.get ('/api/v1/updater/updates', token, authorizeUser, routes.updater.getUpdateInfo);
|
||||
router.post('/api/v1/updater/update', json, token, authorizeAdmin, routes.updater.update);
|
||||
router.post('/api/v1/updater/check_for_updates', json, token, authorizeAdmin, routes.updater.checkForUpdates);
|
||||
router.get ('/api/v1/updater/autoupdate_pattern', token, authorizeAdmin, routes.updater.getAutoupdatePattern);
|
||||
|
||||
@@ -1792,7 +1792,7 @@ async function startRedis(existingInfra) {
|
||||
const allApps = await apps.list();
|
||||
|
||||
for (const app of allApps) {
|
||||
if (!('redis' in app.manifest.addons)) continue; // app doesn't use the addon
|
||||
if (!app.manifest.addons || !('redis' in app.manifest.addons)) continue; // app doesn't use the addon
|
||||
|
||||
const redisName = `redis-${app.id}`;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const apps = require('./apps.js'),
|
||||
docker = require('./docker.js'),
|
||||
hat = require('./hat.js'),
|
||||
infra = require('./infra_version.js'),
|
||||
mounts = require('./mounts.js'),
|
||||
path = require('path'),
|
||||
paths = require('./paths.js'),
|
||||
safe = require('safetydance'),
|
||||
@@ -82,7 +83,7 @@ async function start(existingInfra) {
|
||||
dataDirs.push({ hostDir: '/mnt/volumes', mountDir: '/mnt/volumes' }); // managed volumes
|
||||
const allVolumes = await volumes.list();
|
||||
for (const volume of allVolumes) {
|
||||
if (volume.hostPath.startsWith('/mnt/volumes/')) continue; // skip managed volume
|
||||
if (mounts.isManagedProvider(volume.mountType)) continue; // skip managed volume. these are acessed via /mnt/volumes mount above
|
||||
|
||||
if (!safe.fs.existsSync(volume.hostPath)) {
|
||||
debug(`Ignoring volume host path ${volume.hostPath} since it does not exist`);
|
||||
@@ -100,7 +101,7 @@ async function start(existingInfra) {
|
||||
const readOnly = !serviceConfig.recoveryMode ? '--read-only' : '';
|
||||
const cmd = serviceConfig.recoveryMode ? '/bin/bash -c \'echo "Debug mode. Sleeping" && sleep infinity\'' : '';
|
||||
|
||||
const mounts = dataDirs.map(v => `-v "${v.hostDir}:${v.mountDir}"`).join(' ');
|
||||
const volumeMounts = dataDirs.map(v => `-v "${v.hostDir}:${v.mountDir}"`).join(' ');
|
||||
const runCmd = `docker run --restart=always -d --name=sftp \
|
||||
--hostname sftp \
|
||||
--net cloudron \
|
||||
@@ -112,7 +113,7 @@ async function start(existingInfra) {
|
||||
-m ${memoryLimit} \
|
||||
--memory-swap -1 \
|
||||
-p 222:22 \
|
||||
${mounts} \
|
||||
${volumeMounts} \
|
||||
-e CLOUDRON_SFTP_TOKEN=${cloudronToken} \
|
||||
-v ${paths.SFTP_KEYS_DIR}:/etc/ssh:ro \
|
||||
--label isCloudronManaged=true \
|
||||
|
||||
@@ -20,7 +20,7 @@ exports = module.exports = {
|
||||
|
||||
const SUDO = '/usr/bin/sudo';
|
||||
|
||||
// default encoding utf8, no shell, separate args, wait for process to finish
|
||||
// default encoding utf8, no shell, handles input, separate args, wait for process to finish
|
||||
async function execArgs(tag, file, args, options) {
|
||||
assert.strictEqual(typeof tag, 'string');
|
||||
assert.strictEqual(typeof file, 'string');
|
||||
@@ -111,7 +111,7 @@ function sudo(tag, args, options, callback) {
|
||||
|
||||
cp.stdout.on('data', (data) => {
|
||||
if (options.captureStdout) stdoutResult += data.toString('utf8');
|
||||
process.stdout.write(data); // do not use debug to avoid double timestamps when calling backupupload.js
|
||||
if (!options.quiet) process.stdout.write(data); // do not use debug to avoid double timestamps when calling backupupload.js
|
||||
});
|
||||
cp.stderr.on('data', (data) => {
|
||||
process.stderr.write(data); // do not use debug to avoid double timestamps when calling backupupload.js
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
getProviderStatus,
|
||||
getAvailableSize,
|
||||
|
||||
upload,
|
||||
@@ -35,23 +34,12 @@ const assert = require('assert'),
|
||||
debug = require('debug')('box:storage/filesystem'),
|
||||
df = require('../df.js'),
|
||||
fs = require('fs'),
|
||||
mounts = require('../mounts.js'),
|
||||
path = require('path'),
|
||||
paths = require('../paths.js'),
|
||||
readdirp = require('readdirp'),
|
||||
safe = require('safetydance'),
|
||||
shell = require('../shell.js');
|
||||
|
||||
async function getProviderStatus(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
// Check filesystem is mounted so we don't write into the actual folder on disk
|
||||
if (!mounts.isManagedProvider(apiConfig.provider) && apiConfig.provider !== 'mountpoint') return await mounts.getStatus(apiConfig.provider, apiConfig.backupFolder);
|
||||
|
||||
const hostPath = mounts.isManagedProvider(apiConfig.provider) ? paths.MANAGED_BACKUP_MOUNT_DIR : apiConfig.mountPoint;
|
||||
return await mounts.getStatus(apiConfig.provider, hostPath); // { state, message }
|
||||
}
|
||||
|
||||
// the du call in the function below requires root
|
||||
async function getAvailableSize(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
@@ -88,7 +76,7 @@ async function upload(apiConfig, backupFilePath) {
|
||||
const [mkdirError] = await safe(fs.promises.mkdir(path.dirname(backupFilePath), { recursive: true }));
|
||||
if (mkdirError) throw new BoxError(BoxError.FS_ERROR, `Error creating directory ${backupFilePath}: ${mkdirError.message}`);
|
||||
|
||||
await safe(fs.promises.unlink(backupFilePath), { debug }); // remove any hardlink
|
||||
await safe(fs.promises.unlink(backupFilePath)); // remove any hardlink
|
||||
|
||||
return {
|
||||
stream: fs.createWriteStream(backupFilePath, { autoClose: true }),
|
||||
@@ -171,7 +159,7 @@ async function copy(apiConfig, oldFilePath, newFilePath, progressCallback) {
|
||||
cpOptions += apiConfig.noHardlinks ? '' : 'l'; // this will hardlink backups saving space
|
||||
|
||||
if (apiConfig.provider === PROVIDER_SSHFS) {
|
||||
const identityFilePath = `/home/yellowtent/platformdata/sshfs/id_rsa_${apiConfig.mountOptions.host}`;
|
||||
const identityFilePath = path.join(paths.SSHFS_KEYS_DIR, `id_rsa_${apiConfig.mountOptions.host}`);
|
||||
|
||||
const sshOptions = [ '-o', '"StrictHostKeyChecking no"', '-i', identityFilePath, '-p', apiConfig.mountOptions.port, `${apiConfig.mountOptions.user}@${apiConfig.mountOptions.host}` ];
|
||||
const sshArgs = sshOptions.concat([ 'cp', cpOptions, oldFilePath.replace('/mnt/cloudronbackup/', ''), newFilePath.replace('/mnt/cloudronbackup/', '') ]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
getProviderStatus,
|
||||
getAvailableSize,
|
||||
|
||||
upload,
|
||||
@@ -61,12 +60,6 @@ function getBucket(apiConfig) {
|
||||
return new GCS(gcsConfig).bucket(apiConfig.bucket);
|
||||
}
|
||||
|
||||
async function getProviderStatus(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
return { state: 'active' };
|
||||
}
|
||||
|
||||
async function getAvailableSize(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
// for the other API calls we leave it to the backend to retry. this allows
|
||||
// them to tune the concurrency based on failures/rate limits accordingly
|
||||
exports = module.exports = {
|
||||
getProviderStatus,
|
||||
getAvailableSize,
|
||||
|
||||
upload,
|
||||
@@ -44,12 +43,6 @@ function injectPrivateFields(newConfig, currentConfig) {
|
||||
// in-place injection of tokens and api keys which came in with constants.SECRET_PLACEHOLDER
|
||||
}
|
||||
|
||||
async function getProviderStatus(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
return { state: 'active' };
|
||||
}
|
||||
|
||||
async function getAvailableSize(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
getProviderStatus,
|
||||
getAvailableSize,
|
||||
|
||||
upload,
|
||||
@@ -21,13 +20,8 @@ exports = module.exports = {
|
||||
|
||||
const assert = require('assert'),
|
||||
BoxError = require('../boxerror.js'),
|
||||
debug = require('debug')('box:storage/noop');
|
||||
|
||||
async function getProviderStatus(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
return { state: 'active' };
|
||||
}
|
||||
debug = require('debug')('box:storage/noop'),
|
||||
fs = require('fs');
|
||||
|
||||
async function getAvailableSize(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
@@ -35,15 +29,18 @@ async function getAvailableSize(apiConfig) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function upload(apiConfig, backupFilePath, sourceStream, callback) {
|
||||
async function upload(apiConfig, backupFilePath) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
assert.strictEqual(typeof backupFilePath, 'string');
|
||||
assert.strictEqual(typeof sourceStream, 'object');
|
||||
assert.strictEqual(typeof callback, 'function');
|
||||
|
||||
debug('upload: %s', backupFilePath);
|
||||
debug(`upload: ${backupFilePath}`);
|
||||
|
||||
callback(null);
|
||||
const uploadStream = fs.createWriteStream('/dev/null');
|
||||
|
||||
return {
|
||||
stream: uploadStream,
|
||||
async finish() {}
|
||||
};
|
||||
}
|
||||
|
||||
async function exists(apiConfig, backupFilePath) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
getProviderStatus,
|
||||
getAvailableSize,
|
||||
|
||||
upload,
|
||||
@@ -92,12 +91,6 @@ function getS3Config(apiConfig) {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async function getProviderStatus(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
return { state: 'active' };
|
||||
}
|
||||
|
||||
async function getAvailableSize(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
@@ -133,7 +126,7 @@ async function upload(apiConfig, backupFilePath) {
|
||||
stream: passThrough,
|
||||
async finish() {
|
||||
const [error, data] = await safe(uploadPromise);
|
||||
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `Upload error: ${error.message}`);
|
||||
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `Upload error: code: ${error.code} message: ${error.message}`); // sometimes message is null
|
||||
debug(`Upload finished. ${JSON.stringify(data)}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -300,12 +300,9 @@ async function getLogs(unit, options) {
|
||||
assert.strictEqual(typeof unit, 'string');
|
||||
assert(options && typeof options === 'object');
|
||||
|
||||
debug(`Getting logs for ${unit}`);
|
||||
|
||||
let logFile = '';
|
||||
if (unit === 'box') logFile = path.join(paths.LOG_DIR, 'box.log'); // box.log is at the top
|
||||
else throw new BoxError(BoxError.BAD_FIELD, `No such unit '${unit}'`);
|
||||
if (unit !== 'box') throw new BoxError(BoxError.BAD_FIELD, `No such unit '${unit}'`);
|
||||
|
||||
const logFile = path.join(paths.LOG_DIR, 'box.log');
|
||||
const cp = logs.tail([logFile], { lines: options.lines, follow: options.follow });
|
||||
|
||||
const logStream = new logs.LogStream({ format: options.format || 'json', source: unit });
|
||||
|
||||
@@ -160,11 +160,8 @@ describe('Storage', function () {
|
||||
format: 'tgz'
|
||||
};
|
||||
|
||||
it('upload works', function (done) {
|
||||
noop.upload(gBackupConfig, 'file', { }, function (error) {
|
||||
expect(error).to.be(null);
|
||||
done();
|
||||
});
|
||||
it('upload works', async function () {
|
||||
await noop.upload(gBackupConfig, 'file', {});
|
||||
});
|
||||
|
||||
it('can download file', async function () {
|
||||
|
||||
@@ -14,8 +14,6 @@ const assert = require('assert'),
|
||||
paths = require('./paths.js'),
|
||||
safe = require('safetydance');
|
||||
|
||||
const TRANSLATION_FOLDER = path.join(paths.DASHBOARD_DIR, 'translation');
|
||||
|
||||
// to be used together with getTranslations() => { translations, fallback }
|
||||
function translate(input, assets) {
|
||||
assert.strictEqual(typeof input, 'string');
|
||||
@@ -51,21 +49,23 @@ function translate(input, assets) {
|
||||
}
|
||||
|
||||
async function getTranslations() {
|
||||
const fallback = safe.JSON.parse(fs.readFileSync(path.join(TRANSLATION_FOLDER, 'en.json'), 'utf8'));
|
||||
if (!fallback) debug(`getTranslations: Fallback language en not found. ${safe.error.message}`);
|
||||
const fallbackData = fs.readFileSync(path.join(paths.TRANSLATIONS_DIR, 'en.json'), 'utf8');
|
||||
if (!fallbackData) debug(`getTranslations: Fallback language en not found. ${safe.error.message}`);
|
||||
const fallback = safe.JSON.parse(fallbackData) || {};
|
||||
|
||||
const lang = await cloudron.getLanguage();
|
||||
|
||||
const translations = safe.JSON.parse(fs.readFileSync(path.join(TRANSLATION_FOLDER, lang + '.json'), 'utf8'));
|
||||
if (!translations) debug(`getTranslations: Requested language ${lang} not found. ${safe.error.message}`);
|
||||
const translationData = safe.fs.readFileSync(path.join(paths.TRANSLATIONS_DIR, `${lang}.json`), 'utf8');
|
||||
if (!translationData) debug(`getTranslations: Requested language ${lang} not found. ${safe.error.message}`);
|
||||
const translations = safe.JSON.parse(translationData) || {};
|
||||
|
||||
return { translations: translations || {}, fallback: fallback || {} };
|
||||
return { translations, fallback };
|
||||
}
|
||||
|
||||
async function listLanguages() {
|
||||
const [error, result] = await safe(fs.promises.readdir(TRANSLATION_FOLDER));
|
||||
const [error, result] = await safe(fs.promises.readdir(paths.TRANSLATIONS_DIR));
|
||||
if (error) {
|
||||
debug('listLanguages: Failed to list translations. %o', error);
|
||||
debug(`listLanguages: Failed to list translations. %${error.message}`);
|
||||
return [ 'en' ]; // we always return english to avoid dashboard breakage
|
||||
}
|
||||
|
||||
|
||||
@@ -55,10 +55,13 @@ function validateHostPath(hostPath, mountType) {
|
||||
if (hostPath === '/') return new BoxError(BoxError.BAD_FIELD, 'hostPath cannot be /');
|
||||
|
||||
if (!hostPath.endsWith('/')) hostPath = hostPath + '/'; // ensure trailing slash for the prefix matching to work
|
||||
const allowedPaths = [ '/mnt/', '/media/', '/srv/', '/opt/' ];
|
||||
|
||||
const allowedPaths = [ '/mnt/', '/media/', '/srv/', '/opt/' ];
|
||||
if (!allowedPaths.some(p => hostPath.startsWith(p))) return new BoxError(BoxError.BAD_FIELD, 'hostPath must be under /mnt, /media, /opt or /srv');
|
||||
|
||||
const reservedPaths = [ `${paths.VOLUMES_MOUNT_DIR}/` ];
|
||||
if (reservedPaths.some(p => hostPath.startsWith(p))) return new BoxError(BoxError.BAD_FIELD, 'hostPath is reserved');
|
||||
|
||||
if (!constants.TEST) { // we expect user to have already mounted this
|
||||
const stat = safe.fs.lstatSync(hostPath);
|
||||
if (!stat) return new BoxError(BoxError.BAD_FIELD, 'hostPath does not exist. Please create it on the server first');
|
||||
|
||||
59
syslog.js
59
syslog.js
@@ -12,11 +12,33 @@ const debug = require('debug')('syslog:server'),
|
||||
net = require('net'),
|
||||
path = require('path'),
|
||||
paths = require('./src/paths.js'),
|
||||
parser = require('nsyslog-parser-2'),
|
||||
util = require('util');
|
||||
|
||||
let gServer = null;
|
||||
|
||||
// https://docs.docker.com/engine/logging/drivers/syslog/
|
||||
// example: <34>1 2023-09-07T14:33:22Z myhost myapp 1234 5678 [exampleSDID@32473 iut="3" eventSource="Application"] An example message
|
||||
function parseRFC5424Message(rawMessage) {
|
||||
const syslogRegex = /^<(\d+)>(\d+) (\S+) (\S+) (\S+) (\S+) (\S+) (?:\[(.*?)\])?(.*)$/s; // /s means .* will match newline
|
||||
|
||||
const match = rawMessage.match(syslogRegex);
|
||||
if (!match) return null;
|
||||
|
||||
const [, pri, version, timestamp, hostname, appName, procId, msgId, structuredData, message] = match;
|
||||
|
||||
return {
|
||||
pri: parseInt(pri, 10), // priority
|
||||
version: parseInt(version, 10), // version
|
||||
timestamp, // timestamp
|
||||
hostname, // hostname
|
||||
appName, // app name
|
||||
procId, // process ID
|
||||
msgId, // message ID
|
||||
structuredData: structuredData ? structuredData : null, // structured data (if present)
|
||||
message // message
|
||||
};
|
||||
}
|
||||
|
||||
async function start() {
|
||||
debug('==========================================');
|
||||
debug(' Cloudron Syslog Daemon ');
|
||||
@@ -29,33 +51,24 @@ async function start() {
|
||||
});
|
||||
|
||||
gServer.on('connection', function (socket) {
|
||||
socket.on('data', function (msg) {
|
||||
const lines = msg.toString().split('\n'); // may be multiline data
|
||||
socket.on('data', function (data) {
|
||||
const msg = data.toString('utf8');
|
||||
const info = parseRFC5424Message(msg);
|
||||
if (!info) return debug('Unable to parse:', msg);
|
||||
if (!info.appName) return debug('Ignore unknown app:', msg);
|
||||
|
||||
lines.forEach(function (msg) {
|
||||
if (!msg) return;
|
||||
const appLogDir = path.join(paths.LOG_DIR, info.appName);
|
||||
|
||||
const info = parser(msg);
|
||||
|
||||
if (!info || !info.appName) return debug('Ignore unknown app log:', msg);
|
||||
|
||||
// remove line breaks to avoid holes in the log file
|
||||
// we do not ignore empty log lines, to allow gaps for potential ease of readability
|
||||
const message = info.message.replace(/[\n\r]+/g, '');
|
||||
|
||||
const appLogDir = path.join(paths.LOG_DIR, info.appName);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(appLogDir, { recursive: true });
|
||||
fs.appendFileSync(`${appLogDir}/app.log`, info.ts.toISOString() + ' ' + message + '\n');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
try {
|
||||
fs.mkdirSync(appLogDir, { recursive: true });
|
||||
fs.appendFileSync(`${appLogDir}/app.log`, `${info.timestamp} ${info.message.trim()}\n`);
|
||||
} catch (error) {
|
||||
debug(error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', function (error) {
|
||||
console.error(`socket error: ${error}`);
|
||||
debug(`socket error: ${error}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user