Merge branch 'louislam:master' into italian-translation-update

pull/361/head
Ioma Taani 3 years ago committed by GitHub
commit 0f4bc5850b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -20,6 +20,11 @@ yarn.lock
app.json app.json
CODE_OF_CONDUCT.md CODE_OF_CONDUCT.md
CONTRIBUTING.md CONTRIBUTING.md
CNAME
install.sh
SECURITY.md
tsconfig.json
### .gitignore content (commented rules are duplicated) ### .gitignore content (commented rules are duplicated)

@ -107,10 +107,21 @@ Telegram Notification Sample:
If you love this project, please consider giving me a ⭐. If you love this project, please consider giving me a ⭐.
## 🗣️ Discussion
You can also discuss or ask for help in [Issues](https://github.com/louislam/uptime-kuma/issues).
I think the real "Discussion" tab is hard to use, as it is reddit-like flow, I always missed new comments.
## Contribute ## Contribute
If you want to report a bug or request a new feature. Free feel to open a new issue. If you want to report a bug or request a new feature. Free feel to open a [new issue](https://github.com/louislam/uptime-kuma/issues).
If you want to translate Uptime Kuma into your langauge, please read: https://github.com/louislam/uptime-kuma/tree/master/src/languages
If you want to modify Uptime Kuma, this guideline may be useful for you: https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md If you want to modify Uptime Kuma, this guideline may be useful for you: https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md
English proofreading is needed too because my grammar is not that great sadly. Feel free to correct my grammar in this readme, source code, or wiki. English proofreading is needed too because my grammar is not that great sadly. Feel free to correct my grammar in this readme, source code, or wiki.

@ -0,0 +1,10 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
-- For sendHeartbeatList
CREATE INDEX monitor_time_index ON heartbeat (monitor_id, time);
-- For sendImportantHeartbeatList
CREATE INDEX monitor_important_time_index ON heartbeat (monitor_id, important,time);
COMMIT;

@ -24,7 +24,7 @@ RUN npm install --legacy-peer-deps && npm run build && npm prune
EXPOSE 3001 EXPOSE 3001
VOLUME ["/app/data"] VOLUME ["/app/data"]
HEALTHCHECK --interval=600s --timeout=130s --start-period=300s CMD node extra/healthcheck.js HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD node extra/healthcheck.js
CMD ["node", "server/server.js"] CMD ["node", "server/server.js"]
FROM release AS nightly FROM release AS nightly

@ -19,7 +19,7 @@ RUN npm install --legacy-peer-deps && npm run build && npm prune
EXPOSE 3001 EXPOSE 3001
VOLUME ["/app/data"] VOLUME ["/app/data"]
HEALTHCHECK --interval=600s --timeout=130s --start-period=300s CMD node extra/healthcheck.js HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD node extra/healthcheck.js
CMD ["node", "server/server.js"] CMD ["node", "server/server.js"]
FROM release AS nightly FROM release AS nightly

@ -11,7 +11,7 @@ if (process.env.SSL_KEY && process.env.SSL_CERT) {
let options = { let options = {
host: process.env.HOST || "127.0.0.1", host: process.env.HOST || "127.0.0.1",
port: parseInt(process.env.PORT) || 3001, port: parseInt(process.env.PORT) || 3001,
timeout: 120 * 1000, timeout: 28 * 1000,
}; };
let request = client.request(options, (res) => { let request = client.request(options, (res) => {

@ -32,19 +32,16 @@ async function sendNotificationList(socket) {
async function sendHeartbeatList(socket, monitorID, toUser = false, overwrite = false) { async function sendHeartbeatList(socket, monitorID, toUser = false, overwrite = false) {
const timeLogger = new TimeLogger(); const timeLogger = new TimeLogger();
let list = await R.find("heartbeat", ` let list = await R.getAll(`
monitor_id = ? SELECT * FROM heartbeat
WHERE monitor_id = ?
ORDER BY time DESC ORDER BY time DESC
LIMIT 100 LIMIT 100
`, [ `, [
monitorID, monitorID,
]) ])
let result = []; let result = list.reverse();
for (let bean of list) {
result.unshift(bean.toJSON());
}
if (toUser) { if (toUser) {
io.to(socket.userID).emit("heartbeatList", monitorID, result, overwrite); io.to(socket.userID).emit("heartbeatList", monitorID, result, overwrite);

@ -36,7 +36,11 @@ class Database {
// Change to WAL // Change to WAL
await R.exec("PRAGMA journal_mode = WAL"); await R.exec("PRAGMA journal_mode = WAL");
await R.exec("PRAGMA cache_size = -12000");
console.log("SQLite config:");
console.log(await R.getAll("PRAGMA journal_mode")); console.log(await R.getAll("PRAGMA journal_mode"));
console.log(await R.getAll("PRAGMA cache_size"));
} }
static async patch() { static async patch() {

@ -409,58 +409,59 @@ class Monitor extends BeanModel {
static async sendUptime(duration, io, monitorID, userID) { static async sendUptime(duration, io, monitorID, userID) {
const timeLogger = new TimeLogger(); const timeLogger = new TimeLogger();
let sec = duration * 3600; const startTime = R.isoDateTime(dayjs.utc().subtract(duration, "hour"));
let heartbeatList = await R.getAll(` // Handle if heartbeat duration longer than the target duration
SELECT duration, time, status // e.g. If the last beat's duration is bigger that the 24hrs window, it will use the duration between the (beat time - window margin) (THEN case in SQL)
let result = await R.getRow(`
SELECT
-- SUM all duration, also trim off the beat out of time window
SUM(
CASE
WHEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400 < duration
THEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400
ELSE duration
END
) AS total_duration,
-- SUM all uptime duration, also trim off the beat out of time window
SUM(
CASE
WHEN (status = 1)
THEN
CASE
WHEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400 < duration
THEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400
ELSE duration
END
END
) AS uptime_duration
FROM heartbeat FROM heartbeat
WHERE time > DATETIME('now', ? || ' hours') WHERE time > ?
AND monitor_id = ? `, [ AND monitor_id = ?
-duration, `, [
startTime, startTime, startTime, startTime, startTime,
monitorID, monitorID,
]); ]);
timeLogger.print(`[Monitor: ${monitorID}][${duration}] sendUptime`); timeLogger.print(`[Monitor: ${monitorID}][${duration}] sendUptime`);
let downtime = 0; let totalDuration = result.total_duration;
let total = 0; let uptimeDuration = result.uptime_duration;
let uptime; let uptime = 0;
// Special handle for the first heartbeat only
if (heartbeatList.length === 1) {
if (heartbeatList[0].status === 1) { if (totalDuration > 0) {
uptime = 1; uptime = uptimeDuration / totalDuration;
} else { if (uptime < 0) {
uptime = 0; uptime = 0;
} }
} else { } else {
for (let row of heartbeatList) { // Handle new monitor with only one beat, because the beat's duration = 0
let value = parseInt(row.duration) let status = parseInt(await R.getCell("SELECT `status` FROM heartbeat WHERE monitor_id = ?", [ monitorID ]));
let time = row.time console.log("here???" + status);
if (status === UP) {
// Handle if heartbeat duration longer than the target duration uptime = 1;
// e.g. Heartbeat duration = 28hrs, but target duration = 24hrs
if (value > sec) {
let trim = dayjs.utc().diff(dayjs(time), "second");
value = sec - trim;
if (value < 0) {
value = 0;
}
}
total += value;
if (row.status === 0 || row.status === 2) {
downtime += value;
}
}
uptime = (total - downtime) / total;
if (uptime < 0) {
uptime = 0;
} }
} }

@ -30,10 +30,15 @@ class SMTP extends NotificationProvider {
// send mail with defined transport object // send mail with defined transport object
await transporter.sendMail({ await transporter.sendMail({
from: `"Uptime Kuma" <${notification.smtpFrom}>`, from: notification.smtpFrom,
cc: notification.smtpCC,
bcc: notification.smtpBCC,
to: notification.smtpTo, to: notification.smtpTo,
subject: msg, subject: msg,
text: bodyTextContent, text: bodyTextContent,
tls: {
rejectUnauthorized: notification.smtpIgnoreTLSError || false,
},
}); });
return "Sent Successfully."; return "Sent Successfully.";

@ -593,6 +593,82 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
} }
}); });
socket.on("uploadBackup", async (uploadedJSON, callback) => {
try {
checkLogin(socket)
let backupData = JSON.parse(uploadedJSON);
console.log(`Importing Backup, User ID: ${socket.userID}, Version: ${backupData.version}`)
let notificationList = backupData.notificationList;
let monitorList = backupData.monitorList;
if (notificationList.length >= 1) {
for (let i = 0; i < notificationList.length; i++) {
let notification = JSON.parse(notificationList[i].config);
await Notification.save(notification, null, socket.userID)
}
}
if (monitorList.length >= 1) {
for (let i = 0; i < monitorList.length; i++) {
let monitor = {
name: monitorList[i].name,
type: monitorList[i].type,
url: monitorList[i].url,
interval: monitorList[i].interval,
hostname: monitorList[i].hostname,
maxretries: monitorList[i].maxretries,
port: monitorList[i].port,
keyword: monitorList[i].keyword,
ignoreTls: monitorList[i].ignoreTls,
upsideDown: monitorList[i].upsideDown,
maxredirects: monitorList[i].maxredirects,
accepted_statuscodes: monitorList[i].accepted_statuscodes,
dns_resolve_type: monitorList[i].dns_resolve_type,
dns_resolve_server: monitorList[i].dns_resolve_server,
notificationIDList: {},
}
let bean = R.dispense("monitor")
let notificationIDList = monitor.notificationIDList;
delete monitor.notificationIDList;
monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
delete monitor.accepted_statuscodes;
bean.import(monitor)
bean.user_id = socket.userID
await R.store(bean)
await updateMonitorNotification(bean.id, notificationIDList)
if (monitorList[i].active == 1) {
await startMonitor(socket.userID, bean.id);
} else {
await pauseMonitor(socket.userID, bean.id);
}
}
await sendNotificationList(socket)
await sendMonitorList(socket);
}
callback({
ok: true,
msg: "Backup successfully restored.",
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("clearEvents", async (monitorID, callback) => { socket.on("clearEvents", async (monitorID, callback) => {
try { try {
checkLogin(socket) checkLogin(socket)

@ -37,7 +37,7 @@
<input id="name" v-model="notification.name" type="text" class="form-control" required> <input id="name" v-model="notification.name" type="text" class="form-control" required>
</div> </div>
<Telegram v-if="notification.type === 'telegram'"></Telegram> <Telegram v-if="notification.type === 'telegram'" />
<!-- TODO: Convert all into vue components, but not an easy task. --> <!-- TODO: Convert all into vue components, but not an easy task. -->
@ -65,49 +65,7 @@
</div> </div>
</template> </template>
<template v-if="notification.type === 'smtp'"> <SMTP v-if="notification.type === 'smtp'" />
<div class="mb-3">
<label for="hostname" class="form-label">Hostname</label>
<input id="hostname" v-model="notification.smtpHost" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="port" class="form-label">Port</label>
<input id="port" v-model="notification.smtpPort" type="number" class="form-control" required min="0" max="65535" step="1">
</div>
<div class="mb-3">
<div class="form-check">
<input id="secure" v-model="notification.smtpSecure" class="form-check-input" type="checkbox" value="">
<label class="form-check-label" for="secure">
Secure
</label>
</div>
<div class="form-text">
Generally, true for 465, false for other ports.
</div>
</div>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" v-model="notification.smtpUsername" type="text" class="form-control" autocomplete="false">
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<HiddenInput id="password" v-model="notification.smtpPassword" :required="true" autocomplete="one-time-code"></HiddenInput>
</div>
<div class="mb-3">
<label for="from-email" class="form-label">From Email</label>
<input id="from-email" v-model="notification.smtpFrom" type="email" class="form-control" required autocomplete="false">
</div>
<div class="mb-3">
<label for="to-email" class="form-label">To Email</label>
<input id="to-email" v-model="notification.smtpTo" type="email" class="form-control" required autocomplete="false">
</div>
</template>
<template v-if="notification.type === 'discord'"> <template v-if="notification.type === 'discord'">
<div class="mb-3"> <div class="mb-3">
@ -437,8 +395,8 @@
<!-- DEPRECATED! Please create vue component in "./src/components/notifications/{notification name}.vue" --> <!-- DEPRECATED! Please create vue component in "./src/components/notifications/{notification name}.vue" -->
<div class="mb-3"> <div class="mb-3 mt-4">
<hr class="dropdown-divider"> <hr class="dropdown-divider mb-4">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input v-model="notification.isDefault" class="form-check-input" type="checkbox"> <input v-model="notification.isDefault" class="form-check-input" type="checkbox">
@ -456,6 +414,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm"> <button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
{{ $t("Delete") }} {{ $t("Delete") }}
@ -481,19 +440,18 @@
<script lang="ts"> <script lang="ts">
import { Modal } from "bootstrap" import { Modal } from "bootstrap"
import { ucfirst } from "../util.ts" import { ucfirst } from "../util.ts"
import axios from "axios";
import Confirm from "./Confirm.vue"; import Confirm from "./Confirm.vue";
import HiddenInput from "./HiddenInput.vue"; import HiddenInput from "./HiddenInput.vue";
import Telegram from "./notifications/Telegram.vue"; import Telegram from "./notifications/Telegram.vue";
import { useToast } from "vue-toastification" import SMTP from "./notifications/SMTP.vue";
const toast = useToast();
export default { export default {
components: { components: {
Confirm, Confirm,
HiddenInput, HiddenInput,
Telegram, Telegram,
SMTP,
}, },
props: {}, props: {},
data() { data() {
@ -504,8 +462,8 @@ export default {
notification: { notification: {
name: "", name: "",
type: null, type: null,
gotifyPriority: 8,
isDefault: false, isDefault: false,
// Do not set default value here, please scroll to show()
}, },
appriseInstalled: false, appriseInstalled: false,
} }
@ -558,9 +516,10 @@ export default {
isDefault: false, isDefault: false,
} }
// Default set to Telegram // Set Default value here
this.notification.type = "telegram" this.notification.type = "telegram";
this.notification.gotifyPriority = 8 this.notification.gotifyPriority = 8;
this.notification.smtpSecure = false;
} }
this.modal.show() this.modal.show()

@ -0,0 +1,75 @@
<template>
<div class="mb-3">
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
<input id="hostname" v-model="$parent.notification.smtpHost" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="port" class="form-label">{{ $t("Port") }}</label>
<input id="port" v-model="$parent.notification.smtpPort" type="number" class="form-control" required min="0" max="65535" step="1">
</div>
<div class="mb-3">
<label for="secure" class="form-label">Secure</label>
<select id="secure" v-model="$parent.notification.smtpSecure" class="form-select">
<option :value="false">None / STARTTLS (25, 587)</option>
<option :value="true">TLS (465)</option>
</select>
</div>
<div class="mb-3">
<div class="form-check">
<input id="ignore-tls-error" v-model="$parent.notification.smtpIgnoreTLSError" class="form-check-input" type="checkbox" value="">
<label class="form-check-label" for="ignore-tls-error">
Ignore TLS Error
</label>
</div>
</div>
<div class="mb-3">
<label for="username" class="form-label">{{ $t("Username") }}</label>
<input id="username" v-model="$parent.notification.smtpUsername" type="text" class="form-control" autocomplete="false">
</div>
<div class="mb-3">
<label for="password" class="form-label">{{ $t("Password") }}</label>
<HiddenInput id="password" v-model="$parent.notification.smtpPassword" :required="true" autocomplete="one-time-code"></HiddenInput>
</div>
<div class="mb-3">
<label for="from-email" class="form-label">From Email</label>
<input id="from-email" v-model="$parent.notification.smtpFrom" type="text" class="form-control" required autocomplete="false" placeholder="&quot;Uptime Kuma&quot; &lt;example@kuma.pet&gt;">
<div class="form-text">
</div>
</div>
<div class="mb-3">
<label for="to-email" class="form-label">To Email</label>
<input id="to-email" v-model="$parent.notification.smtpTo" type="text" class="form-control" required autocomplete="false" placeholder="example2@kuma.pet, example3@kuma.pet">
</div>
<div class="mb-3">
<label for="to-cc" class="form-label">CC</label>
<input id="to-cc" v-model="$parent.notification.smtpCC" type="text" class="form-control" autocomplete="false">
</div>
<div class="mb-3">
<label for="to-bcc" class="form-label">BCC</label>
<input id="to-bcc" v-model="$parent.notification.smtpBCC" type="text" class="form-control" autocomplete="false">
</div>
</template>
<script>
import HiddenInput from "../HiddenInput.vue";
export default {
components: {
HiddenInput,
},
data() {
return {
name: "smtp",
}
},
}
</script>

@ -113,11 +113,19 @@ export default {
"Create your admin account": "Erstelle dein Admin Konto", "Create your admin account": "Erstelle dein Admin Konto",
"Repeat Password": "Wiederhole das Passwort", "Repeat Password": "Wiederhole das Passwort",
"Resource Record Type": "Resource Record Type", "Resource Record Type": "Resource Record Type",
"Import/Export Backup": "Import/Export Backup",
"Export": "Export",
"Import": "Import",
respTime: "Antw. Zeit (ms)", respTime: "Antw. Zeit (ms)",
notAvailableShort: "N/A", notAvailableShort: "N/A",
"Default enabled": "Standardmäßig aktiviert", "Default enabled": "Standardmäßig aktiviert",
"Also apply to existing monitors": "Auch für alle existierenden Monitore aktivieren", "Also apply to existing monitors": "Auch für alle existierenden Monitore aktivieren",
enableDefaultNotificationDescription: "Für jeden neuen Monitor wird diese Benachrichtigung standardmäßig aktiviert. Die Benachrichtigung kann weiterhin für jeden Monitor separat deaktiviert werden.", enableDefaultNotificationDescription: "Für jeden neuen Monitor wird diese Benachrichtigung standardmäßig aktiviert. Die Benachrichtigung kann weiterhin für jeden Monitor separat deaktiviert werden.",
Create: "Erstellen", Create: "Erstellen",
"Auto Get": "Auto Get" "Auto Get": "Auto Get",
backupDescription: "Es können alle Monitore und alle Benachrichtigungen in einer JSON-Datei gesichert werden.",
backupDescription2: "PS: Verlaufs- und Ereignisdaten sind nicht enthalten.",
backupDescription3: "Sensible Daten wie Benachrichtigungstoken sind in der Exportdatei enthalten, bitte bewahre sie sorgfältig auf.",
alertNoFile: "Bitte wähle eine Datei zum importieren aus.",
alertWrongFileType: "Bitte wähle eine JSON Datei aus.",
} }

@ -111,6 +111,9 @@ export default {
"Last Result": "Last Result", "Last Result": "Last Result",
"Create your admin account": "Create your admin account", "Create your admin account": "Create your admin account",
"Repeat Password": "Repeat Password", "Repeat Password": "Repeat Password",
"Import/Export Backup": "Import/Export Backup",
"Export": "Export",
"Import": "Import",
respTime: "Resp. Time (ms)", respTime: "Resp. Time (ms)",
notAvailableShort: "N/A", notAvailableShort: "N/A",
"Default enabled": "Default enabled", "Default enabled": "Default enabled",
@ -119,5 +122,10 @@ export default {
"Clear Data": "Clear Data", "Clear Data": "Clear Data",
Events: "Events", Events: "Events",
Heartbeats: "Heartbeats", Heartbeats: "Heartbeats",
"Auto Get": "Auto Get" "Auto Get": "Auto Get",
backupDescription: "You can backup all monitors and all notifications into a JSON file.",
backupDescription2: "PS: History and event data is not included.",
backupDescription3: "Sensitive data such as notification tokens is included in the export file, please keep it carefully.",
alertNoFile: "Please select a file to import.",
alertWrongFileType: "Please select a JSON file.",
} }

@ -254,6 +254,10 @@ export default {
this.importantHeartbeatList = {} this.importantHeartbeatList = {}
}, },
uploadBackup(uploadedJSON, callback) {
socket.emit("uploadBackup", uploadedJSON, callback)
},
clearEvents(monitorID, callback) { clearEvents(monitorID, callback) {
socket.emit("clearEvents", monitorID, callback) socket.emit("clearEvents", monitorID, callback)
}, },

@ -120,6 +120,27 @@
</form> </form>
</template> </template>
<h2 class="mt-5 mb-2">{{ $t("Import/Export Backup") }}</h2>
<p>
{{ $t("backupDescription") }} <br />
({{ $t("backupDescription2") }}) <br />
</p>
<div class="input-group mb-3">
<button class="btn btn-outline-primary" @click="downloadBackup">{{ $t("Export") }}</button>
<button type="button" class="btn btn-outline-primary" :disabled="processing" @click="importBackup">
<div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
{{ $t("Import") }}
</button>
<input id="importBackup" type="file" class="form-control" accept="application/json">
</div>
<div v-if="importAlert" class="alert alert-danger mt-3" style="padding: 6px 16px;">
{{ importAlert }}
</div>
<p><strong>{{ $t("backupDescription3") }}</strong></p>
<h2 class="mt-5 mb-2">{{ $t("Advanced") }}</h2> <h2 class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
<div class="mb-3"> <div class="mb-3">
@ -275,6 +296,8 @@ export default {
}, },
loaded: false, loaded: false,
importAlert: null,
processing: false,
} }
}, },
watch: { watch: {
@ -351,6 +374,52 @@ export default {
this.$root.storage().removeItem("token"); this.$root.storage().removeItem("token");
}, },
downloadBackup() {
let time = dayjs().format("YYYY_MM_DD-hh_mm_ss");
let fileName = `Uptime_Kuma_Backup_${time}.json`;
let monitorList = Object.values(this.$root.monitorList);
let exportData = {
version: this.$root.info.version,
notificationList: this.$root.notificationList,
monitorList: monitorList,
}
exportData = JSON.stringify(exportData);
let downloadItem = document.createElement("a");
downloadItem.setAttribute("href", "data:application/json;charset=utf-8," + encodeURI(exportData));
downloadItem.setAttribute("download", fileName);
downloadItem.click();
},
importBackup() {
this.processing = true;
let uploadItem = document.getElementById("importBackup").files;
if (uploadItem.length <= 0) {
this.processing = false;
return this.importAlert = this.$t("alertNoFile")
}
if (uploadItem.item(0).type !== "application/json") {
this.processing = false;
return this.importAlert = this.$t("alertWrongFileType")
}
let fileReader = new FileReader();
fileReader.readAsText(uploadItem.item(0));
fileReader.onload = item => {
this.$root.uploadBackup(item.target.result, (res) => {
this.processing = false;
if (res.ok) {
toast.success(res.msg);
} else {
toast.error(res.msg);
}
})
}
},
clearStatistics() { clearStatistics() {
this.$root.clearStatistics((res) => { this.$root.clearStatistics((res) => {
if (res.ok) { if (res.ok) {
@ -388,6 +457,18 @@ export default {
.btn-check:hover + .btn-outline-primary { .btn-check:hover + .btn-outline-primary {
color: #000; color: #000;
} }
#importBackup {
&::file-selector-button {
color: $primary;
background-color: $dark-bg;
}
&:hover:not(:disabled):not([readonly])::file-selector-button {
color: $dark-font-color2;
background-color: $primary;
}
}
} }
footer { footer {

Loading…
Cancel
Save