From b9a6088f50e1680fbab3c7fa3ef4a03cfaf3caca Mon Sep 17 00:00:00 2001 From: Nelson Chan Date: Fri, 30 Jul 2021 14:54:40 +0800 Subject: [PATCH 01/77] WIP: Add login page, nav bar buttons --- src/icon.js | 4 +-- src/layouts/Layout.vue | 10 ++++++ src/main.js | 7 ++-- src/pages/Login.vue | 78 ++++++++++++++++++++++++++++++++++++++++++ src/pages/Settings.vue | 6 ---- 5 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 src/pages/Login.vue diff --git a/src/icon.js b/src/icon.js index d8ea36d6..7911e9cc 100644 --- a/src/icon.js +++ b/src/icon.js @@ -1,10 +1,10 @@ import { library } from "@fortawesome/fontawesome-svg-core" -import { faCog, faEdit, faList, faPause, faPlay, faPlus, faTachometerAlt, faTrash } from "@fortawesome/free-solid-svg-icons" +import { faCog, faEdit, faList, faPause, faPlay, faPlus, faSignInAlt, faSignOutAlt, faTachometerAlt, faTrash } from "@fortawesome/free-solid-svg-icons" //import { fa } from '@fortawesome/free-regular-svg-icons' import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome" // Add Free Font Awesome Icons here // https://fontawesome.com/v5.15/icons?d=gallery&p=2&s=solid&m=free -library.add(faCog, faTachometerAlt, faEdit, faPlus, faPause, faPlay, faTrash, faList) +library.add(faCog, faTachometerAlt, faEdit, faPlus, faPause, faPlay, faTrash, faList, faSignInAlt, faSignOutAlt) export { FontAwesomeIcon } diff --git a/src/layouts/Layout.vue b/src/layouts/Layout.vue index 8e725500..2f5b1cb8 100644 --- a/src/layouts/Layout.vue +++ b/src/layouts/Layout.vue @@ -23,6 +23,16 @@ Settings + + diff --git a/src/main.js b/src/main.js index 6eb296dc..8621b898 100644 --- a/src/main.js +++ b/src/main.js @@ -13,6 +13,7 @@ import Dashboard from "./pages/Dashboard.vue"; import DashboardHome from "./pages/DashboardHome.vue"; import Details from "./pages/Details.vue"; import EditMonitor from "./pages/EditMonitor.vue"; +import Login from "./pages/Login.vue"; import Settings from "./pages/Settings.vue"; import Setup from "./pages/Setup.vue"; @@ -57,9 +58,11 @@ const routes = [ }, ], }, - + { + path: "/login", + component: Login, + }, ], - }, { path: "/setup", diff --git a/src/pages/Login.vue b/src/pages/Login.vue new file mode 100644 index 00000000..4b08de06 --- /dev/null +++ b/src/pages/Login.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue index d1d3599b..d2695edf 100644 --- a/src/pages/Settings.vue +++ b/src/pages/Settings.vue @@ -53,12 +53,6 @@ - -
- -
From d953ba7c60ab6da17358308d56b8ada93a8b7004 Mon Sep 17 00:00:00 2001 From: LouisLam Date: Sat, 11 Sep 2021 03:12:25 +0800 Subject: [PATCH 02/77] update to 1.6.1 --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index e1c405cf..342a73cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uptime-kuma", - "version": "1.6.0", + "version": "1.6.1", "license": "MIT", "repository": { "type": "git", @@ -19,11 +19,11 @@ "build": "vite build", "vite-preview-dist": "vite preview --host", "build-docker": "npm run build-docker-debian && npm run build-docker-alpine", - "build-docker-alpine": "docker buildx build -f dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:alpine -t louislam/uptime-kuma:1-alpine -t louislam/uptime-kuma:1.6.0-alpine --target release . --push", - "build-docker-debian": "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.6.0 -t louislam/uptime-kuma:debian -t louislam/uptime-kuma:1-debian -t louislam/uptime-kuma:1.6.0-debian --target release . --push", + "build-docker-alpine": "docker buildx build -f dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:alpine -t louislam/uptime-kuma:1-alpine -t louislam/uptime-kuma:1.6.1-alpine --target release . --push", + "build-docker-debian": "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.6.1 -t louislam/uptime-kuma:debian -t louislam/uptime-kuma:1-debian -t louislam/uptime-kuma:1.6.1-debian --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-amd64": "docker buildx build --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain", - "setup": "git checkout 1.6.0 && npm install --legacy-peer-deps && node node_modules/esbuild/install.js && npm run build && npm prune", + "setup": "git checkout 1.6.1 && npm install --legacy-peer-deps && node node_modules/esbuild/install.js && npm run build && npm prune", "update-version": "node extra/update-version.js", "mark-as-nightly": "node extra/mark-as-nightly.js", "reset-password": "node extra/reset-password.js", From 0969be5981b3672d35f4fb9d230d4bc1a3f0fd74 Mon Sep 17 00:00:00 2001 From: LouisLam Date: Sat, 11 Sep 2021 15:08:56 +0800 Subject: [PATCH 03/77] Revert "WIP: Add login page, nav bar buttons" This reverts commit b9a6088f50e1680fbab3c7fa3ef4a03cfaf3caca. --- src/icon.js | 4 +-- src/layouts/Layout.vue | 10 ------ src/main.js | 7 ++-- src/pages/Login.vue | 78 ------------------------------------------ src/pages/Settings.vue | 6 ++++ 5 files changed, 10 insertions(+), 95 deletions(-) delete mode 100644 src/pages/Login.vue diff --git a/src/icon.js b/src/icon.js index 7911e9cc..d8ea36d6 100644 --- a/src/icon.js +++ b/src/icon.js @@ -1,10 +1,10 @@ import { library } from "@fortawesome/fontawesome-svg-core" -import { faCog, faEdit, faList, faPause, faPlay, faPlus, faSignInAlt, faSignOutAlt, faTachometerAlt, faTrash } from "@fortawesome/free-solid-svg-icons" +import { faCog, faEdit, faList, faPause, faPlay, faPlus, faTachometerAlt, faTrash } from "@fortawesome/free-solid-svg-icons" //import { fa } from '@fortawesome/free-regular-svg-icons' import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome" // Add Free Font Awesome Icons here // https://fontawesome.com/v5.15/icons?d=gallery&p=2&s=solid&m=free -library.add(faCog, faTachometerAlt, faEdit, faPlus, faPause, faPlay, faTrash, faList, faSignInAlt, faSignOutAlt) +library.add(faCog, faTachometerAlt, faEdit, faPlus, faPause, faPlay, faTrash, faList) export { FontAwesomeIcon } diff --git a/src/layouts/Layout.vue b/src/layouts/Layout.vue index 2f5b1cb8..8e725500 100644 --- a/src/layouts/Layout.vue +++ b/src/layouts/Layout.vue @@ -23,16 +23,6 @@ Settings - - diff --git a/src/main.js b/src/main.js index 8621b898..6eb296dc 100644 --- a/src/main.js +++ b/src/main.js @@ -13,7 +13,6 @@ import Dashboard from "./pages/Dashboard.vue"; import DashboardHome from "./pages/DashboardHome.vue"; import Details from "./pages/Details.vue"; import EditMonitor from "./pages/EditMonitor.vue"; -import Login from "./pages/Login.vue"; import Settings from "./pages/Settings.vue"; import Setup from "./pages/Setup.vue"; @@ -58,11 +57,9 @@ const routes = [ }, ], }, - { - path: "/login", - component: Login, - }, + ], + }, { path: "/setup", diff --git a/src/pages/Login.vue b/src/pages/Login.vue deleted file mode 100644 index 4b08de06..00000000 --- a/src/pages/Login.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - - - diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue index d2695edf..d1d3599b 100644 --- a/src/pages/Settings.vue +++ b/src/pages/Settings.vue @@ -53,6 +53,12 @@
+ +
+ +
From 4b0a8087a21188eb2973ea0f0ef5d98ef9608632 Mon Sep 17 00:00:00 2001 From: LouisLam Date: Sat, 11 Sep 2021 16:22:30 +0800 Subject: [PATCH 04/77] do not connect to socket io for status page --- src/main.js | 5 + src/mixins/socket.js | 333 +++++++++++++++++++++------------------ src/pages/StatusPage.vue | 36 +++++ 3 files changed, 222 insertions(+), 152 deletions(-) create mode 100644 src/pages/StatusPage.vue diff --git a/src/main.js b/src/main.js index 21793faa..a4129db8 100644 --- a/src/main.js +++ b/src/main.js @@ -20,6 +20,7 @@ import EditMonitor from "./pages/EditMonitor.vue"; import Settings from "./pages/Settings.vue"; import Setup from "./pages/Setup.vue"; import List from "./pages/List.vue"; +import StatusPage from "./pages/StatusPage.vue"; import { appName } from "./util.ts"; @@ -94,6 +95,10 @@ const routes = [ path: "/setup", component: Setup, }, + { + path: "/status-page", + component: StatusPage, + }, ] const router = createRouter({ diff --git a/src/mixins/socket.js b/src/mixins/socket.js index 9771db0d..5ffdf752 100644 --- a/src/mixins/socket.js +++ b/src/mixins/socket.js @@ -4,6 +4,10 @@ const toast = useToast() let socket; +const noSocketIOPage = [ + "/status-page", +]; + export default { data() { @@ -14,6 +18,8 @@ export default { firstConnect: true, connected: false, connectCount: 0, + initedSocketIO: false, + }, remember: (localStorage.remember !== "0"), allowLoginDialog: false, // Allowed to show login dialog, but "loggedIn" have to be true too. This exists because prevent the login dialog show 0.1s in first before the socket server auth-ed. @@ -31,162 +37,177 @@ export default { created() { window.addEventListener("resize", this.onResize); - - let protocol = (location.protocol === "https:") ? "wss://" : "ws://"; - - let wsHost; - const env = process.env.NODE_ENV || "production"; - if (env === "development" || localStorage.dev === "dev") { - wsHost = protocol + location.hostname + ":3001"; - } else { - wsHost = protocol + location.host; - } - - socket = io(wsHost, { - transports: ["websocket"], - }); - - socket.on("info", (info) => { - this.info = info; - }); - - socket.on("setup", (monitorID, data) => { - this.$router.push("/setup") - }); - - socket.on("autoLogin", (monitorID, data) => { - this.loggedIn = true; - this.storage().token = "autoLogin"; - this.allowLoginDialog = false; - }); - - socket.on("monitorList", (data) => { - // Add Helper function - Object.entries(data).forEach(([monitorID, monitor]) => { - monitor.getUrl = () => { - try { - return new URL(monitor.url); - } catch (_) { - return null; - } - }; - }); - this.monitorList = data; - }); - - socket.on("notificationList", (data) => { - this.notificationList = data; - }); - - socket.on("heartbeat", (data) => { - if (! (data.monitorID in this.heartbeatList)) { - this.heartbeatList[data.monitorID] = []; - } - - this.heartbeatList[data.monitorID].push(data) - - // Add to important list if it is important - // Also toast - if (data.important) { - - if (data.status === 0) { - toast.error(`[${this.monitorList[data.monitorID].name}] [DOWN] ${data.msg}`, { - timeout: false, - }); - } else if (data.status === 1) { - toast.success(`[${this.monitorList[data.monitorID].name}] [Up] ${data.msg}`, { - timeout: 20000, - }); - } else { - toast(`[${this.monitorList[data.monitorID].name}] ${data.msg}`); - } - - if (! (data.monitorID in this.importantHeartbeatList)) { - this.importantHeartbeatList[data.monitorID] = []; - } - - this.importantHeartbeatList[data.monitorID].unshift(data) - } - }); - - socket.on("heartbeatList", (monitorID, data, overwrite = false) => { - if (! (monitorID in this.heartbeatList) || overwrite) { - this.heartbeatList[monitorID] = data; - } else { - this.heartbeatList[monitorID] = data.concat(this.heartbeatList[monitorID]) - } - }); - - socket.on("avgPing", (monitorID, data) => { - this.avgPingList[monitorID] = data - }); - - socket.on("uptime", (monitorID, type, data) => { - this.uptimeList[`${monitorID}_${type}`] = data - }); - - socket.on("certInfo", (monitorID, data) => { - this.certInfoList[monitorID] = JSON.parse(data) - }); - - socket.on("importantHeartbeatList", (monitorID, data, overwrite) => { - if (! (monitorID in this.importantHeartbeatList) || overwrite) { - this.importantHeartbeatList[monitorID] = data; - } else { - this.importantHeartbeatList[monitorID] = data.concat(this.importantHeartbeatList[monitorID]) - } - }); - - socket.on("connect_error", (err) => { - console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`); - this.connectionErrorMsg = `Cannot connect to the socket server. [${err}] Reconnecting...`; - this.socket.connected = false; - this.socket.firstConnect = false; - }); - - socket.on("disconnect", () => { - console.log("disconnect") - this.connectionErrorMsg = "Lost connection to the socket server. Reconnecting..."; - this.socket.connected = false; - }); - - socket.on("connect", () => { - console.log("connect") - this.socket.connectCount++; - this.socket.connected = true; - - // Reset Heartbeat list if it is re-connect - if (this.socket.connectCount >= 2) { - this.clearData() - } - - let token = this.storage().token; - - if (token) { - if (token !== "autoLogin") { - this.loginByToken(token) - } else { - - // Timeout if it is not actually auto login - setTimeout(() => { - if (! this.loggedIn) { - this.allowLoginDialog = true; - this.$root.storage().removeItem("token"); - } - }, 5000); - - } - } else { - this.allowLoginDialog = true; - } - - this.socket.firstConnect = false; - }); - + this.initSocketIO(); }, methods: { + initSocketIO(bypass = false) { + // No need to re-init + if (this.socket.initedSocketIO) { + return; + } + + // No need to connect to the socket.io for status page + if (! bypass && noSocketIOPage.includes(location.pathname)) { + return; + } + + this.socket.initedSocketIO = true; + + let protocol = (location.protocol === "https:") ? "wss://" : "ws://"; + + let wsHost; + const env = process.env.NODE_ENV || "production"; + if (env === "development" || localStorage.dev === "dev") { + wsHost = protocol + location.hostname + ":3001"; + } else { + wsHost = protocol + location.host; + } + + socket = io(wsHost, { + transports: ["websocket"], + }); + + socket.on("info", (info) => { + this.info = info; + }); + + socket.on("setup", (monitorID, data) => { + this.$router.push("/setup") + }); + + socket.on("autoLogin", (monitorID, data) => { + this.loggedIn = true; + this.storage().token = "autoLogin"; + this.allowLoginDialog = false; + }); + + socket.on("monitorList", (data) => { + // Add Helper function + Object.entries(data).forEach(([monitorID, monitor]) => { + monitor.getUrl = () => { + try { + return new URL(monitor.url); + } catch (_) { + return null; + } + }; + }); + this.monitorList = data; + }); + + socket.on("notificationList", (data) => { + this.notificationList = data; + }); + + socket.on("heartbeat", (data) => { + if (! (data.monitorID in this.heartbeatList)) { + this.heartbeatList[data.monitorID] = []; + } + + this.heartbeatList[data.monitorID].push(data) + + // Add to important list if it is important + // Also toast + if (data.important) { + + if (data.status === 0) { + toast.error(`[${this.monitorList[data.monitorID].name}] [DOWN] ${data.msg}`, { + timeout: false, + }); + } else if (data.status === 1) { + toast.success(`[${this.monitorList[data.monitorID].name}] [Up] ${data.msg}`, { + timeout: 20000, + }); + } else { + toast(`[${this.monitorList[data.monitorID].name}] ${data.msg}`); + } + + if (! (data.monitorID in this.importantHeartbeatList)) { + this.importantHeartbeatList[data.monitorID] = []; + } + + this.importantHeartbeatList[data.monitorID].unshift(data) + } + }); + + socket.on("heartbeatList", (monitorID, data, overwrite = false) => { + if (! (monitorID in this.heartbeatList) || overwrite) { + this.heartbeatList[monitorID] = data; + } else { + this.heartbeatList[monitorID] = data.concat(this.heartbeatList[monitorID]) + } + }); + + socket.on("avgPing", (monitorID, data) => { + this.avgPingList[monitorID] = data + }); + + socket.on("uptime", (monitorID, type, data) => { + this.uptimeList[`${monitorID}_${type}`] = data + }); + + socket.on("certInfo", (monitorID, data) => { + this.certInfoList[monitorID] = JSON.parse(data) + }); + + socket.on("importantHeartbeatList", (monitorID, data, overwrite) => { + if (! (monitorID in this.importantHeartbeatList) || overwrite) { + this.importantHeartbeatList[monitorID] = data; + } else { + this.importantHeartbeatList[monitorID] = data.concat(this.importantHeartbeatList[monitorID]) + } + }); + + socket.on("connect_error", (err) => { + console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`); + this.connectionErrorMsg = `Cannot connect to the socket server. [${err}] Reconnecting...`; + this.socket.connected = false; + this.socket.firstConnect = false; + }); + + socket.on("disconnect", () => { + console.log("disconnect") + this.connectionErrorMsg = "Lost connection to the socket server. Reconnecting..."; + this.socket.connected = false; + }); + + socket.on("connect", () => { + console.log("connect") + this.socket.connectCount++; + this.socket.connected = true; + + // Reset Heartbeat list if it is re-connect + if (this.socket.connectCount >= 2) { + this.clearData() + } + + let token = this.storage().token; + + if (token) { + if (token !== "autoLogin") { + this.loginByToken(token) + } else { + + // Timeout if it is not actually auto login + setTimeout(() => { + if (! this.loggedIn) { + this.allowLoginDialog = true; + this.$root.storage().removeItem("token"); + } + }, 5000); + + } + } else { + this.allowLoginDialog = true; + } + + this.socket.firstConnect = false; + }); + + }, + storage() { return (this.remember) ? localStorage : sessionStorage; }, @@ -336,6 +357,14 @@ export default { localStorage.remember = (this.remember) ? "1" : "0" }, + // Reconnect the socket io, if status-page to dashboard + "$route.fullPath"(newValue, oldValue) { + if (noSocketIOPage.includes(newValue)) { + return; + } + this.initSocketIO(); + }, + }, } diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue new file mode 100644 index 00000000..7cb493ad --- /dev/null +++ b/src/pages/StatusPage.vue @@ -0,0 +1,36 @@ + + + From 3e25f0e9d9470ef26219a48549704092109f4fd0 Mon Sep 17 00:00:00 2001 From: LouisLam Date: Sat, 11 Sep 2021 19:40:03 +0800 Subject: [PATCH 05/77] [Status Page] WIP: Checkpoint --- server/server.js | 29 ++++++++++-- server/util-server.js | 12 +++++ src/icon.js | 4 +- src/layouts/Layout.vue | 11 +++++ src/mixins/socket.js | 8 ++-- src/mixins/theme.js | 16 +++++-- src/pages/StatusPage.vue | 97 +++++++++++++++++++++++++++++++++++++--- 7 files changed, 159 insertions(+), 18 deletions(-) diff --git a/server/server.js b/server/server.js index 2949c4be..aec4528d 100644 --- a/server/server.js +++ b/server/server.js @@ -26,7 +26,7 @@ console.log("Importing this project modules"); debug("Importing Monitor"); const Monitor = require("./model/monitor"); debug("Importing Settings"); -const { getSettings, setSettings, setting, initJWTSecret } = require("./util-server"); +const { getSettings, setSettings, setting, initJWTSecret, allowDevAllOrigin } = require("./util-server"); debug("Importing Notification"); const { Notification } = require("./notification"); @@ -127,7 +127,9 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString(); console.log("Adding route") + // *************************** // Normal Router here + // *************************** // Robots.txt app.get("/robots.txt", async (_request, response) => { @@ -147,7 +149,28 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString(); app.use("/", express.static("dist")); - // Universal Route Handler, must be at the end + // *************************** + // Public API + // *************************** + + // Status Page Config + app.get("/api/status-page/config", async (_request, response) => { + allowDevAllOrigin(response); + let config = getSettings("statusPage"); + + if (! config.statusPageTheme) { + config.statusPageTheme = "light"; + } + + response.json(config); + }); + + // Status Page Polling Data + app.get("/api/status-page", async (_request, response) => { + allowDevAllOrigin(response); + }); + + // Universal Route Handler, must be at the end of all express route. app.get("*", async (_request, response) => { response.send(indexHTML); }); @@ -172,7 +195,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString(); }); // *************************** - // Public API + // Public Socket API // *************************** socket.on("loginByToken", async (token, callback) => { diff --git a/server/util-server.js b/server/util-server.js index a2fef065..67bf1302 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -5,6 +5,7 @@ const { debug } = require("../src/util"); const passwordHash = require("./password-hash"); const dayjs = require("dayjs"); const { Resolver } = require("dns"); +const { allowAllOrigin } = require("./util-server"); /** * Init or reset JWT secret @@ -271,3 +272,14 @@ exports.getTotalClientInRoom = (io, roomName) => { return 0; } } + +exports.allowDevAllOrigin = (res) => { + if (process.env.NODE_ENV === "development") { + exports.allowAllOrigin(res); + } +} + +exports.allowAllOrigin = (res) => { + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); +} diff --git a/src/icon.js b/src/icon.js index 58583f0f..7e7c7dd6 100644 --- a/src/icon.js +++ b/src/icon.js @@ -1,10 +1,10 @@ import { library } from "@fortawesome/fontawesome-svg-core" -import { faCog, faEdit, faPlus, faPause, faPlay, faTachometerAlt, faTrash, faList, faArrowAltCircleUp, faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons" +import { faCog, faEdit, faPlus, faPause, faPlay, faTachometerAlt, faTrash, faList, faArrowAltCircleUp, faEye, faEyeSlash, faCheckCircle, faStream } from "@fortawesome/free-solid-svg-icons" //import { fa } from '@fortawesome/free-regular-svg-icons' import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome" // Add Free Font Awesome Icons here // https://fontawesome.com/v5.15/icons?d=gallery&p=2&s=solid&m=free -library.add(faCog, faEdit, faPlus, faPause, faPlay, faTachometerAlt, faTrash, faList, faArrowAltCircleUp, faEye, faEyeSlash); +library.add(faCog, faEdit, faPlus, faPause, faPlay, faTachometerAlt, faTrash, faList, faArrowAltCircleUp, faEye, faEyeSlash, faCheckCircle, faStream); export { FontAwesomeIcon } diff --git a/src/layouts/Layout.vue b/src/layouts/Layout.vue index 467ae53a..b7b8688b 100644 --- a/src/layouts/Layout.vue +++ b/src/layouts/Layout.vue @@ -18,6 +18,11 @@