Merge branch 'louislam:master' into clear-monitor-data

This commit is contained in:
Ponkhy 2021-08-31 23:22:45 +02:00 committed by GitHub
commit 1341d220ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1769 additions and 2010 deletions

View file

@ -77,6 +77,8 @@ module.exports = {
"no-empty": ["error", { "no-empty": ["error", {
"allowEmptyCatch": true "allowEmptyCatch": true
}], }],
"no-control-regex": "off" "no-control-regex": "off",
"one-var": ["error", "never"],
"max-statements-per-line": ["error", { "max": 1 }]
}, },
} }

View file

@ -73,6 +73,12 @@ For example, recently, because I am not a python expert, I spent a 2 hours to re
npm install --dev npm install --dev
``` ```
For npm@7, you need --legacy-peer-deps
```
npm install --legacy-peer-deps --dev
```
# Backend Dev # Backend Dev
```bash ```bash

View file

@ -19,14 +19,6 @@ It is a self-hosted monitoring tool like "Uptime Robot".
## 🔧 How to Install ## 🔧 How to Install
### 🚀 Installer via CLI
Interactive CLI installer, supports Docker or without Docker.
```bash
curl -o kuma_install.sh http://git.kuma.pet/install.sh && sudo bash kuma_install.sh
```
### 🐳 Docker ### 🐳 Docker
```bash ```bash
@ -36,6 +28,25 @@ docker run -d --restart=always -p 3001:3001 -v uptime-kuma:/app/data --name upti
Browse to http://localhost:3001 after started. Browse to http://localhost:3001 after started.
### 💪🏻 Without Docker
Required Tools: Node.js >= 14, git and pm2.
```bash
git clone https://github.com/louislam/uptime-kuma.git
cd uptime-kuma
npm run setup
# Option 1. Try it
node server/server.js
# (Recommended) Option 2. Run in background using PM2
# Install PM2 if you don't have: npm install pm2 -g
pm2 start server/server.js --name uptime-kuma
```
Browse to http://localhost:3001 after started.
### Advanced Installation ### Advanced Installation
If you need more options or need to browse via a reserve proxy, please read: If you need more options or need to browse via a reserve proxy, please read:

View file

@ -2,22 +2,13 @@
FROM node:14-alpine3.12 AS release FROM node:14-alpine3.12 AS release
WORKDIR /app WORKDIR /app
# split the sqlite install here, so that it can caches the prebuilt
RUN apk add --no-cache --virtual .build-deps make g++ python3 python3-dev && \
ln -s /usr/bin/python3 /usr/bin/python && \
npm install better-sqlite3@7.4.3 bcrypt@5.0.1 && \
apk del .build-deps && \
rm -f /usr/bin/python
# Touching above code may causes sqlite3 re-compile again, painful slow.
# Install apprise # Install apprise
RUN apk add --no-cache python3 py3-cryptography py3-pip py3-six py3-yaml py3-click py3-markdown py3-requests py3-requests-oauthlib RUN apk add --no-cache python3 py3-cryptography py3-pip py3-six py3-yaml py3-click py3-markdown py3-requests py3-requests-oauthlib
RUN pip3 --no-cache-dir install apprise && \ RUN pip3 --no-cache-dir install apprise && \
rm -rf /root/.cache rm -rf /root/.cache
COPY . . COPY . .
RUN npm install && npm run build && npm prune RUN npm install --legacy-peer-deps && npm run build && npm prune
EXPOSE 3001 EXPOSE 3001
VOLUME ["/app/data"] VOLUME ["/app/data"]

3444
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "uptime-kuma", "name": "uptime-kuma",
"version": "1.3.2", "version": "1.5.0",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
@ -20,10 +20,10 @@
"update": "", "update": "",
"build": "vite build", "build": "vite build",
"vite-preview-dist": "vite preview --host", "vite-preview-dist": "vite preview --host",
"build-docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.3.2 --target release . --push", "build-docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.5.0 --target release . --push",
"build-docker-nightly": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly --target nightly . --push", "build-docker-nightly": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly --target nightly . --push",
"build-docker-nightly-amd64": "docker buildx build --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain", "build-docker-nightly-amd64": "docker buildx build --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain",
"setup": "git checkout 1.3.2 && npm install && npm run build", "setup": "git checkout 1.5.0 && npm install --legacy-peer-deps && npm run build && npm prune",
"update-version": "node extra/update-version.js", "update-version": "node extra/update-version.js",
"mark-as-nightly": "node extra/mark-as-nightly.js", "mark-as-nightly": "node extra/mark-as-nightly.js",
"reset-password": "node extra/reset-password.js", "reset-password": "node extra/reset-password.js",
@ -40,13 +40,13 @@
"@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-regular-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/vue-fontawesome": "^3.0.0-4", "@fortawesome/vue-fontawesome": "^3.0.0-4",
"@louislam/better-sqlite3-with-prebuilds": "^7.4.3",
"@popperjs/core": "^2.9.3", "@popperjs/core": "^2.9.3",
"args-parser": "^1.3.0", "args-parser": "^1.3.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"bcrypt": "^5.0.1", "bcryptjs": "^2.4.3",
"better-sqlite3": "^7.4.3",
"bootstrap": "^5.1.0", "bootstrap": "^5.1.0",
"chart.js": "^3.5.0", "chart.js": "^3.5.1",
"chartjs-adapter-dayjs": "^1.0.0", "chartjs-adapter-dayjs": "^1.0.0",
"command-exists": "^1.2.9", "command-exists": "^1.2.9",
"compare-versions": "^3.6.0", "compare-versions": "^3.6.0",
@ -60,7 +60,7 @@
"password-hash": "^1.2.2", "password-hash": "^1.2.2",
"prom-client": "^13.2.0", "prom-client": "^13.2.0",
"prometheus-api-metrics": "^3.2.0", "prometheus-api-metrics": "^3.2.0",
"redbean-node": "0.1.1", "redbean-node": "0.1.2",
"socket.io": "^4.1.3", "socket.io": "^4.1.3",
"socket.io-client": "^4.1.3", "socket.io-client": "^4.1.3",
"tcp-ping": "^0.1.1", "tcp-ping": "^0.1.1",

View file

@ -7,7 +7,7 @@ dayjs.extend(timezone)
const axios = require("axios"); const axios = require("axios");
const { Prometheus } = require("../prometheus"); const { Prometheus } = require("../prometheus");
const { debug, UP, DOWN, PENDING, flipStatus, TimeLogger } = require("../../src/util"); const { debug, UP, DOWN, PENDING, flipStatus, TimeLogger } = require("../../src/util");
const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode } = require("../util-server"); const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom } = require("../util-server");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model"); const { BeanModel } = require("redbean-node/dist/bean-model");
const { Notification } = require("../notification") const { Notification } = require("../notification")
@ -353,10 +353,16 @@ class Monitor extends BeanModel {
} }
static async sendStats(io, monitorID, userID) { static async sendStats(io, monitorID, userID) {
const hasClients = getTotalClientInRoom(io, userID) > 0;
if (hasClients) {
await Monitor.sendAvgPing(24, io, monitorID, userID); await Monitor.sendAvgPing(24, io, monitorID, userID);
await Monitor.sendUptime(24, io, monitorID, userID); await Monitor.sendUptime(24, io, monitorID, userID);
await Monitor.sendUptime(24 * 30, io, monitorID, userID); await Monitor.sendUptime(24 * 30, io, monitorID, userID);
await Monitor.sendCertInfo(io, monitorID, userID); await Monitor.sendCertInfo(io, monitorID, userID);
} else {
debug("No clients in the room, no need to send stats");
}
} }
/** /**

View file

@ -96,9 +96,16 @@ class Notification {
return okMsg; return okMsg;
} }
let url = monitorJSON["url"] === "https://" ? monitorJSON["hostname"] : monitorJSON["url"] let url;
if (monitorJSON["type"] === "port") {
url = monitorJSON["hostname"];
if (monitorJSON["port"]) { if (monitorJSON["port"]) {
url += ":" + monitorJSON[port]; url += ":" + monitorJSON["port"];
}
} else {
url = monitorJSON["url"];
} }
// If heartbeatJSON is not null, we go into the normal alerting loop. // If heartbeatJSON is not null, we go into the normal alerting loop.

View file

@ -1,5 +1,5 @@
const passwordHashOld = require("password-hash"); const passwordHashOld = require("password-hash");
const bcrypt = require("bcrypt"); const bcrypt = require("bcryptjs");
const saltRounds = 10; const saltRounds = 10;
exports.generate = function (password) { exports.generate = function (password) {

View file

@ -1,14 +1,13 @@
// https://github.com/ben-bradley/ping-lite/blob/master/ping-lite.js // https://github.com/ben-bradley/ping-lite/blob/master/ping-lite.js
// Fixed on Windows // Fixed on Windows
const net = require("net"); const net = require("net");
const spawn = require("child_process").spawn, const spawn = require("child_process").spawn;
events = require("events"), const events = require("events");
fs = require("fs"), const fs = require("fs");
WIN = /^win/.test(process.platform), const WIN = /^win/.test(process.platform);
LIN = /^linux/.test(process.platform), const LIN = /^linux/.test(process.platform);
MAC = /^darwin/.test(process.platform); const MAC = /^darwin/.test(process.platform);
FBSD = /^freebsd/.test(process.platform); const FBSD = /^freebsd/.test(process.platform);
const { debug } = require("../src/util");
module.exports = Ping; module.exports = Ping;
@ -22,15 +21,17 @@ function Ping(host, options) {
events.EventEmitter.call(this); events.EventEmitter.call(this);
const timeout = 10;
if (WIN) { if (WIN) {
this._bin = "c:/windows/system32/ping.exe"; this._bin = "c:/windows/system32/ping.exe";
this._args = (options.args) ? options.args : [ "-n", "1", "-w", "5000", host ]; this._args = (options.args) ? options.args : [ "-n", "1", "-w", timeout * 1000, host ];
this._regmatch = /[><=]([0-9.]+?)ms/; this._regmatch = /[><=]([0-9.]+?)ms/;
} else if (LIN) { } else if (LIN) {
this._bin = "/bin/ping"; this._bin = "/bin/ping";
const defaultArgs = [ "-n", "-w", "2", "-c", "1", host ]; const defaultArgs = [ "-n", "-w", timeout, "-c", "1", host ];
if (net.isIPv6(host) || options.ipv6) { if (net.isIPv6(host) || options.ipv6) {
defaultArgs.unshift("-6"); defaultArgs.unshift("-6");
@ -47,13 +48,13 @@ function Ping(host, options) {
this._bin = "/sbin/ping"; this._bin = "/sbin/ping";
} }
this._args = (options.args) ? options.args : [ "-n", "-t", "2", "-c", "1", host ]; this._args = (options.args) ? options.args : [ "-n", "-t", timeout, "-c", "1", host ];
this._regmatch = /=([0-9.]+?) ms/; this._regmatch = /=([0-9.]+?) ms/;
} else if (FBSD) { } else if (FBSD) {
this._bin = "/sbin/ping"; this._bin = "/sbin/ping";
const defaultArgs = [ "-n", "-t", "2", "-c", "1", host ]; const defaultArgs = [ "-n", "-t", timeout, "-c", "1", host ];
if (net.isIPv6(host) || options.ipv6) { if (net.isIPv6(host) || options.ipv6) {
defaultArgs.unshift("-6"); defaultArgs.unshift("-6");
@ -88,7 +89,9 @@ Ping.prototype.send = function (callback) {
return self.emit("result", ms); return self.emit("result", ms);
}; };
let _ended, _exited, _errored; let _ended;
let _exited;
let _errored;
this._ping = spawn(this._bin, this._args); // spawn the binary this._ping = spawn(this._bin, this._args); // spawn the binary
@ -120,9 +123,9 @@ Ping.prototype.send = function (callback) {
}); });
function onEnd() { function onEnd() {
let stdout = this.stdout._stdout, let stdout = this.stdout._stdout;
stderr = this.stderr._stderr, let stderr = this.stderr._stderr;
ms; let ms;
if (stderr) { if (stderr) {
return callback(new Error(stderr)); return callback(new Error(stderr));

View file

@ -248,3 +248,26 @@ exports.checkStatusCode = function (status, accepted_codes) {
return false; return false;
} }
exports.getTotalClientInRoom = (io, roomName) => {
const sockets = io.sockets;
if (! sockets) {
return 0;
}
const adapter = sockets.adapter;
if (! adapter) {
return 0;
}
const room = adapter.rooms.get(roomName);
if (room) {
return room.size;
} else {
return 0;
}
}

View file

@ -131,7 +131,7 @@ h2 {
background-color: #090c10; background-color: #090c10;
color: $dark-font-color; color: $dark-font-color;
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb, ::-webkit-scrollbar-thumb {
background: $dark-border-color; background: $dark-border-color;
} }

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="shadow-box list mb-4"> <div class="shadow-box list mb-3" :class="{ scrollbar: scrollbar }">
<div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3"> <div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3">
{{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link> {{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link>
</div> </div>
@ -34,6 +34,11 @@ export default {
Uptime, Uptime,
HeartbeatBar, HeartbeatBar,
}, },
props: {
scrollbar: {
type: Boolean,
},
},
computed: { computed: {
sortedMonitorList() { sortedMonitorList() {
let result = Object.values(this.$root.monitorList); let result = Object.values(this.$root.monitorList);
@ -83,8 +88,13 @@ export default {
} }
.list { .list {
height: auto; &.scrollbar {
min-height: calc(100vh - 240px); min-height: calc(100vh - 240px);
max-height: calc(100vh - 30px);
overflow-y: auto;
position: sticky;
top: 10px;
}
.item { .item {
display: block; display: block;

106
src/languages/fr.js Normal file
View file

@ -0,0 +1,106 @@
export default {
languageName: "Français (France)",
Settings: "Paramètres",
Dashboard: "Dashboard",
"New Update": "Mise à jour disponible",
Language: "Langue",
Appearance: "Apparence",
Theme: "Thème",
General: "Général",
Version: "Version",
"Check Update On GitHub": "Consulter les mises à jour sur Github",
List: "Lister",
Add: "Ajouter",
"Add New Monitor": "Ajouter un nouveau check",
"Quick Stats": "Résumé",
Up: "En ligne",
Down: "Hors ligne",
Pending: "Dans la file d'attente",
Unknown: "Inconnu",
Pause: "En Pause",
pauseDashboardHome: "Éléments mis en pause",
Name: "Nom",
Status: "État",
DateTime: "Heure",
Message: "Messages",
"No important events": "Pas d'évènements important",
Resume: "Reprendre",
Edit: "Modifier",
Delete: "Supprimer",
Current: "Actuellement",
Uptime: "Uptime",
"Cert Exp.": "Cert Exp.",
days: "Jours",
day: "Jour",
"-day": "Demi-Journée",
hour: "Heure",
"-hour": "Demi-Heure",
checkEverySecond: "Vérifier toutes les {0} secondes",
"Avg.": "Moy.",
Response: "Réponse",
Ping: "Ping",
"Monitor Type": "Type de Monitoring",
Keyword: "Mot-clé",
"Friendly Name": "Nom d'affichage",
URL: "URL",
Hostname: "Nom d'hôte",
Port: "Port",
"Heartbeat Interval": "Intervale de vérifications",
Retries: "Essais",
retriesDescription: "Nombre d'essais avant que le service soit déclaré hors-ligne.",
Advanced: "Avancé",
ignoreTLSError: "Ignorer les erreurs liées au certificat SSL/TLS",
"Upside Down Mode": "Mode inversé",
upsideDownModeDescription: "Si le service est en ligne il sera alors noté hors-ligne et vice-versa.",
"Max. Redirects": "Redirections",
maxRedirectDescription: "Nombre maximal de redirections avant que le service soit noté hors-ligne.",
"Accepted Status Codes": "Codes HTTP",
acceptedStatusCodesDescription: "Si les codes HTTP reçus sont ceux séléctionnés, alors le serveur sera noté en ligne.",
Save: "Sauvegarder",
Notifications: "Notifications",
"Not available, please setup.": "Créez des notifications depuis les paramètres.",
"Setup Notification": "Créer une notification",
Light: "Clair",
Dark: "Sombre",
Auto: "Automatique",
"Theme - Heartbeat Bar": "Voir les services monitorés",
Normal: "Général",
Bottom: "Au dessus",
None: "Neutre",
Timezone: "Fuseau Horaire",
"Search Engine Visibility": "SEO",
"Allow indexing": "Autoriser l'indexation par des moteurs de recherche",
"Discourage search engines from indexing site": "Empêche les moteurs de recherche d'indexer votre site",
"Change Password": "Changer le mot de passe",
"Current Password": "Mot de passe actuel",
"New Password": "Nouveau mot de passe",
"Repeat New Password": "Répéter votre nouveau mot de passe",
passwordNotMatchMsg: "Les mots de passe ne correspondent pas",
"Update Password": "Mettre à jour le mot de passe",
"Disable Auth": "Désactiver l'authentification intégrée",
"Enable Auth": "Activer l'authentification",
Logout: "Se déconnecter",
notificationDescription: "Une fois ajoutée, vous devez l'activer manuellement dans les paramètres de vos hosts.",
Leave: "Quitter",
"I understand, please disable": "Je comprends, je l'ai désactivé",
Confirm: "Confirmer",
Yes: "Oui",
No: "Non",
Username: "Nom d'utilisateur",
Password: "Mot de passe",
"Remember me": "Se souvenir de moi",
Login: "Se connecter",
"No Monitors, please": "Pas de monitor, veuillez ",
"add one": "en ajouter un.",
"Notification Type": "Type de notification",
"Email": "Email",
"Test": "Tester",
keywordDescription: "Le mot clé sera cherché dans la réponse HTML/JSON reçue du site internet.",
"Certificate Info": "Des informations sur le certificat SSL",
deleteMonitorMsg: "Êtes-vous sûr de vouloir supprimer ce monitor ?",
deleteNotificationMsg: "Êtes-vous sûr de vouloir supprimer ce type de notifications ? Une fois désactivée, les services qui l'utilisent ne pourront plus envoyer de notifications.",
"Resolver Server": "Serveur DNS utilisé",
"Resource Record Type": "Type d'enregistrement DNS recherché",
resoverserverDescription: "Le DNS de cloudflare est utilisé par défaut, mais vous pouvez le changer si vous le souhaitez.",
rrtypeDescription: "Veuillez séléctionner un type d'enregistrement DNS",
}

View file

@ -40,19 +40,10 @@
</header> </header>
<main> <main>
<!-- Add :key to disable vue router re-use the same component --> <router-view v-if="$root.loggedIn" />
<router-view v-if="$root.loggedIn" :key="$route.fullPath" />
<Login v-if="! $root.loggedIn && $root.allowLoginDialog" /> <Login v-if="! $root.loggedIn && $root.allowLoginDialog" />
</main> </main>
<footer>
<div class="container-fluid">
Uptime Kuma -
{{ $t("Version") }}: {{ $root.info.version }} -
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a>
</div>
</footer>
<!-- Mobile Only --> <!-- Mobile Only -->
<div v-if="$root.isMobile" style="width: 100%; height: 60px;" /> <div v-if="$root.isMobile" style="width: 100%; height: 60px;" />
<nav v-if="$root.isMobile" class="bottom-nav"> <nav v-if="$root.isMobile" class="bottom-nav">
@ -190,15 +181,6 @@ main {
color: white; color: white;
} }
footer {
color: #aaa;
font-size: 13px;
margin-top: 10px;
padding-bottom: 30px;
margin-left: 10px;
text-align: center;
}
.dark { .dark {
header { header {
background-color: #161b22; background-color: #161b22;

View file

@ -26,6 +26,7 @@ import { appName } from "./util.ts";
import en from "./languages/en"; import en from "./languages/en";
import zhHK from "./languages/zh-HK"; import zhHK from "./languages/zh-HK";
import deDE from "./languages/de-DE"; import deDE from "./languages/de-DE";
import fr from "./languages/fr";
const routes = [ const routes = [
{ {
@ -92,6 +93,7 @@ const languageList = {
en, en,
"zh-HK": zhHK, "zh-HK": zhHK,
"de-DE": deDE, "de-DE": deDE,
"fr": fr,
}; };
const i18n = createI18n({ const i18n = createI18n({

View file

@ -5,11 +5,12 @@
<div> <div>
<router-link to="/add" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("Add New Monitor") }}</router-link> <router-link to="/add" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("Add New Monitor") }}</router-link>
</div> </div>
<MonitorList /> <MonitorList scrollbar="true" />
</div> </div>
<div class="col-12 col-md-7 col-xl-8"> <div class="col-12 col-md-7 col-xl-8 mb-3">
<router-view /> <!-- Add :key to disable vue router re-use the same component -->
<router-view :key="$route.fullPath" />
</div> </div>
</div> </div>
</div> </div>
@ -26,7 +27,6 @@ export default {
data() { data() {
return {} return {}
}, },
} }
</script> </script>

View file

@ -5,7 +5,7 @@
{{ $t("Quick Stats") }} {{ $t("Quick Stats") }}
</h1> </h1>
<div class="shadow-box big-padding text-center"> <div class="shadow-box big-padding text-center mb-4">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3>{{ $t("Up") }}</h3> <h3>{{ $t("Up") }}</h3>
@ -170,7 +170,6 @@ export default {
.shadow-box { .shadow-box {
padding: 20px; padding: 20px;
margin-top: 25px;
} }
table { table {

View file

@ -507,4 +507,5 @@ table {
} }
} }
} }
</style> </style>

View file

@ -155,6 +155,14 @@
</div> </div>
</div> </div>
<footer>
<div class="container-fluid">
Uptime Kuma -
{{ $t("Version") }}: {{ $root.info.version }} -
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a>
</div>
</footer>
<NotificationDialog ref="notificationDialog" /> <NotificationDialog ref="notificationDialog" />
<Confirm ref="confirmDisableAuth" btn-style="btn-danger" :yes-text="$t('I understand, please disable')" :no-text="$t('Leave')" @yes="disableAuth"> <Confirm ref="confirmDisableAuth" btn-style="btn-danger" :yes-text="$t('I understand, please disable')" :no-text="$t('Leave')" @yes="disableAuth">
@ -314,4 +322,12 @@ export default {
color: #000; color: #000;
} }
} }
footer {
color: #aaa;
font-size: 13px;
margin-top: 20px;
padding-bottom: 30px;
text-align: center;
}
</style> </style>