├── CNAME ├── src ├── webhook.cfg ├── urls.cfg ├── js │ ├── getyear.js │ ├── fetchurlsconfig.js │ ├── getclieninfo.js │ ├── swregister.js │ ├── startanimation.js │ ├── scroll.js │ ├── reslogs.js │ ├── manualreload.js │ ├── lastupdated.js │ ├── tooltip.js │ ├── domManipulation.js │ ├── dataProcessing.js │ ├── reloadreports.js │ ├── utils.js │ ├── sw.js │ ├── genReports.js │ ├── timelapsechart.js │ └── scrollreveal.min.js ├── knloopsta.webmanifest ├── index.js └── index.css ├── public ├── logo.png ├── favicon.ico ├── 20240720224751.png ├── 20240721051941.png ├── 20240731003153.png ├── Workflowpermissions.png ├── check │ ├── nodata.svg │ ├── failure.svg │ ├── success.svg │ └── partial.svg └── logo.svg ├── .gitignore ├── robots.txt ├── checkshell ├── fuse.sh ├── servicecheck-local.sh ├── actions-local.sh └── servicecheck.sh ├── .github └── workflows │ ├── deploy-status-pages.yml │ └── service-status-check.yml ├── index.html └── README.md /CNAME: -------------------------------------------------------------------------------- 1 | status.knloop.com -------------------------------------------------------------------------------- /src/webhook.cfg: -------------------------------------------------------------------------------- 1 | push="false" 2 | sys="wx" 3 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shadowqcom/knloop-service-status/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shadowqcom/knloop-service-status/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/20240720224751.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shadowqcom/knloop-service-status/HEAD/public/20240720224751.png -------------------------------------------------------------------------------- /public/20240721051941.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shadowqcom/knloop-service-status/HEAD/public/20240721051941.png -------------------------------------------------------------------------------- /public/20240731003153.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shadowqcom/knloop-service-status/HEAD/public/20240731003153.png -------------------------------------------------------------------------------- /public/Workflowpermissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shadowqcom/knloop-service-status/HEAD/public/Workflowpermissions.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 排除logs目录 2 | logs/ 3 | tmp/ 4 | dist/ 5 | node_modules/ 6 | .cache/ 7 | .parcel-cache 8 | .git/ 9 | 10 | # 排除的文件 11 | .vscode 12 | *.log 13 | CNAME 14 | package-lock.json 15 | package.json -------------------------------------------------------------------------------- /robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: Googlebot 2 | Allow: / 3 | 4 | User-agent: Baiduspider 5 | Allow: / 6 | 7 | User-agent: Bingbot 8 | Allow: / 9 | 10 | User-agent: Bytespider 11 | Allow: / 12 | 13 | User-agent: * 14 | Disallow: / 15 | -------------------------------------------------------------------------------- /checkshell/fuse.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | curl -O https://raw.githubusercontent.com/shadowqcom/knloop-service-status/page/checkshell/actions-local.sh > /dev/null 2>&1 3 | chmod +x ./actions-local.sh 4 | sudo bash ./actions-local.sh 5 | rm -f ./actions-local.sh -------------------------------------------------------------------------------- /src/urls.cfg: -------------------------------------------------------------------------------- 1 | Web=https://knloop.com 2 | # Api=https://api.knloop.com 3 | # OSS=https://file.knloop.com 4 | Dev=https://dev.knloop.com 5 | Mojocn=https://mojocn.org 6 | Mojoo=https://mojoo.org 7 | ShadowQ=https://www.shadowq.com 8 | Google=https://google.com 9 | -------------------------------------------------------------------------------- /src/js/getyear.js: -------------------------------------------------------------------------------- 1 | // 更新页脚年份。 2 | export async function getyear() { 3 | try { 4 | var currentYearElement = document.getElementById("currentYear"); 5 | currentYearElement.textContent = new Date().getFullYear(); 6 | } catch (error) { 7 | console.error("在更新年份时发生错误:", error); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /public/check/nodata.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 8 | -------------------------------------------------------------------------------- /src/js/fetchurlsconfig.js: -------------------------------------------------------------------------------- 1 | import { urlspath } from "../index.js"; 2 | 3 | /** 4 | * 异步获取URL配置列表。 5 | * 6 | * 该函数通过网络请求获取配置文件内容,随后处理这些内容以去除空行和注释行, 7 | * 最终返回一个包含所有有效配置行的数组。 8 | * 9 | * @returns {Promise>} 返回一个Promise,解析为包含配置文件有效行的数组 10 | */ 11 | export async function fetchUrlsConfig() { 12 | const response = await fetch(urlspath); 13 | const configText = await response.text(); 14 | const configLines = configText 15 | .split(/\r\n|\n/) 16 | .filter((entry) => entry !== "") 17 | .filter((line) => !line.trim().startsWith("#")); 18 | return configLines; 19 | } -------------------------------------------------------------------------------- /src/knloopsta.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "knloop status", 3 | "short_name": "knloop", 4 | "start_url": "/index.html", 5 | "description": "零依赖的状态页。", 6 | "display": "standalone", 7 | "background_color": "#000", 8 | "theme_color": "#fff", 9 | "icons": [ 10 | { 11 | "src": "/public/logo.png", 12 | "sizes": "192x192", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/public/logo.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "id": "knloop status" 22 | } 23 | -------------------------------------------------------------------------------- /public/check/failure.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 9 | 11 | -------------------------------------------------------------------------------- /public/check/success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /src/js/getclieninfo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 当文档加载完成时,异步获取并处理客户端追踪数据。 3 | * 该函数主要从一个特定的URL获取客户端信息,并使用这些信息替换文档中特定数据占位符的值。 4 | * 使用fetch API进行异步请求以避免阻塞文档加载 5 | */ 6 | 7 | export async function getclieninfo() { 8 | // 创建数据对象 9 | let data = { 10 | // ip: '0.0.0.0', 11 | // loc: 'shenzhen', 12 | // ts: new Date().toLocaleString(), 13 | uag: navigator.userAgent 14 | }; 15 | 16 | try { 17 | let clientInfoDiv = document.getElementById('clientInfo'); 18 | let spans = clientInfoDiv.getElementsByTagName('span'); 19 | 20 | for (let span of spans) { 21 | let id = span.id; 22 | if (data.hasOwnProperty(id)) { 23 | let originalText = span.innerHTML; 24 | let regex = new RegExp(`\\$${id}`, 'g'); 25 | span.innerHTML = originalText.replace(regex, data[id]); 26 | } 27 | } 28 | } catch (error) { 29 | console.error('Error:', error); 30 | } 31 | } -------------------------------------------------------------------------------- /src/js/swregister.js: -------------------------------------------------------------------------------- 1 | export function swregister() { 2 | window.addEventListener('load', () => { 3 | if ("serviceWorker" in navigator) { 4 | navigator.serviceWorker.register("./src/js/sw.js") 5 | .catch(function () { 6 | ServiceWorkerContainer.register('./src/js/sw.js') 7 | }); 8 | } 9 | }); 10 | 11 | // 检测是否处于 PWA 独立窗口模式。 12 | if (window.matchMedia('(display-mode: standalone)').matches) { 13 | // 如果是 PWA 独立窗口,隐藏滚动条 14 | const style = document.createElement('style'); 15 | style.innerHTML = '::-webkit-scrollbar { display: none; }'; 16 | document.head.appendChild(style); 17 | 18 | // 隐藏头部 19 | document.querySelector('header').style.display = 'none'; 20 | // 重设标题顶部距离 21 | document.querySelector('.headline').style.margin = '1.2rem auto 0rem'; 22 | } 23 | } -------------------------------------------------------------------------------- /public/check/partial.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /src/js/startanimation.js: -------------------------------------------------------------------------------- 1 | // 初始化页面加载动效。 2 | export function initScrollReveal() { 3 | // 添加对文档的 class 操作 4 | const doc = document.documentElement 5 | doc.classList.remove('no-js') 6 | doc.classList.add('js') 7 | 8 | // 初始化 ScrollReveal 9 | const sr = window.sr = ScrollReveal() 10 | 11 | // 使用 ScrollReveal 12 | sr.reveal('.hero-title, .lastUpdatedTime, .pageContainer', { 13 | duration: 1000, 14 | distance: '30px', 15 | easing: 'cubic-bezier(0.5, -0.01, 0, 1.005)', 16 | origin: 'bottom', 17 | interval: 150 18 | }) 19 | 20 | sr.reveal('.bubble-4, .hero-browser-inner, .bubble-1, .bubble-2', { 21 | duration: 1000, 22 | scale: 0.95, 23 | easing: 'cubic-bezier(0.5, -0.01, 0, 1.005)', 24 | interval: 150 25 | }) 26 | 27 | sr.reveal('.feature', { 28 | duration: 600, 29 | distance: '40px', 30 | easing: 'cubic-bezier(0.5, -0.01, 0, 1.005)', 31 | interval: 100, 32 | origin: 'bottom', 33 | viewFactor: 0.5 34 | }) 35 | } -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 14 | 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/deploy-status-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Status Pages 2 | 3 | on: 4 | # 推送触发 5 | push: 6 | branches: ["page"] 7 | 8 | # 定时任务触发 9 | # schedule: 10 | # - cron: "5,30,55 * * * *" 11 | 12 | # 手动触发 13 | workflow_dispatch: 14 | 15 | permissions: 16 | contents: read 17 | pages: write 18 | id-token: write 19 | 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | with: 34 | ref: page 35 | - name: Setup Pages 36 | uses: actions/configure-pages@v5 37 | - name: Upload artifact 38 | uses: actions/upload-pages-artifact@v3 39 | with: 40 | path: "." 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /src/js/scroll.js: -------------------------------------------------------------------------------- 1 | // 将每个状态列表横条滚动到最右端。 2 | export async function scrolltoright() { 3 | var containers = document.querySelectorAll(".statusStreamContainer"); 4 | containers.forEach(function (container) { 5 | const finalScrollLeft = container.scrollWidth; 6 | const currentScrollLeft = container.scrollLeft; 7 | const duration = 700; // 动画持续时间,单位毫秒。 8 | const startTime = performance.now(); 9 | 10 | function step(timestamp) { 11 | const progress = Math.min(1, (timestamp - startTime) / duration); 12 | container.scrollLeft = 13 | currentScrollLeft + (finalScrollLeft - currentScrollLeft) * progress; 14 | 15 | if (progress < 1) { 16 | requestAnimationFrame(step); 17 | } 18 | } 19 | 20 | requestAnimationFrame(step); 21 | }); 22 | } 23 | 24 | export async function scrollheader() { 25 | // 获取头部元素和页面滚动事件 26 | const header = document.querySelector("header"); 27 | window.addEventListener("scroll", () => { 28 | // 只要滚动就添加隐藏类 29 | if (window.scrollY > 128) { 30 | header.classList.add("hidden"); 31 | } else { 32 | header.classList.remove("hidden"); 33 | } 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/js/reslogs.js: -------------------------------------------------------------------------------- 1 | import { logspath } from "../index.js"; 2 | 3 | /** 4 | * 异步函数:获取日志文件内容。 5 | * 6 | * 该函数通过HTTP请求从指定的URL获取日志文件的内容。如果请求成功,它将返回日志文本; 7 | * 如果请求失败,它将抛出一个错误。这个函数使用了fetch API来进行网络请求,并支持使用缓存。 8 | * 9 | * @param {string} key - 日志文件名的关键字,用于构造URL。 10 | * @param {Object} uesCache - 控制是否使用缓存的对象,默认为使用'default'缓存策略。该参数的具体作用取决于fetch函数的实现。 11 | * @returns {Promise} - 返回一个承诺,该承诺解析为日志文件的文本内容。 12 | * @throws {Error} - 如果请求失败,将抛出一个包含错误信息的异常。 13 | */ 14 | export async function reslogs(key, useCache = { cache: 'default' }) { 15 | const url = logspath + "/" + key + "_report.log"; 16 | const urlB = "./logs/" + key + "_report.log"; // 备选logspath 17 | 18 | try { 19 | const response = await fetch(url, useCache); 20 | // 如果请求失败,使用备选logspath 21 | if (!response.ok) { 22 | console.warn('Fetch failed. Attempting to use the alternate logspath.'); 23 | const responseB = await fetch(urlB, useCache); 24 | const responsetext = await responseB.text(); 25 | return responsetext; 26 | } 27 | // 请求成功,返回文本 28 | const responsetext = await response.text(); 29 | return responsetext; 30 | } catch (error) { 31 | console.error(error); 32 | } 33 | } -------------------------------------------------------------------------------- /.github/workflows/service-status-check.yml: -------------------------------------------------------------------------------- 1 | name: Service Status Check 2 | 3 | on: 4 | schedule: 5 | - cron: "1,25,55 * * * *" 6 | 7 | # 添加手动触发事件 8 | workflow_dispatch: 9 | 10 | jobs: 11 | service-check: 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 3 14 | name: Check knloop service status 15 | env: 16 | WEBHOOK_KEY: ${{ secrets.WECHAT_WEBHOOK_KEY }} 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | # 新任务挂起 20 | concurrency: 21 | group: ${{ github.workflow }}-${{ github.ref }} 22 | cancel-in-progress: false 23 | 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v4 27 | with: 28 | ref: page 29 | 30 | - name: Run Shell Script 31 | id: shell_script_run 32 | run: | 33 | chmod +x ./checkshell/servicecheck.sh 34 | bash ./checkshell/servicecheck.sh 35 | 36 | - name: Commit and push changes 37 | run: | 38 | git config --local user.name 'Github Actions' 39 | git config --local user.email 'Actions@knloop.com' 40 | git add -A --force ./logs/ 41 | git commit -m '🆙 [Automated] Update service status logs' 42 | git push origin page -------------------------------------------------------------------------------- /src/js/manualreload.js: -------------------------------------------------------------------------------- 1 | import { showLoadingMask, hideLoadingMask, clearReports, getlastTime } from "./reloadreports.js"; 2 | import { genAllReports, getLastDayStatus } from "./genReports.js"; 3 | import { refreshLastupdatedon } from "./lastupdated.js"; 4 | import {initScrollReveal} from './startanimation.js'; 5 | 6 | export function manualreload() { 7 | document.addEventListener('DOMContentLoaded', () => { 8 | const reloadBtn = document.getElementById('statusImg'); 9 | reloadBtn.addEventListener('click', throttle(reloadall, 1500)); 10 | }); 11 | } 12 | 13 | function throttle(func, wait) { 14 | let lastTime = 0; 15 | return function (...args) { 16 | const now = Date.now(); 17 | if (now - lastTime >= wait) { 18 | func.apply(this, args); 19 | lastTime = now; 20 | } 21 | }; 22 | } 23 | async function reloadall() { 24 | const useCache = { cache: 'reload' }; // 不使用缓存。 25 | const lastTime = await getlastTime(); 26 | initScrollReveal(); 27 | showLoadingMask(); // 显示加载动画 28 | clearReports(); // 清理旧的报告 29 | await genAllReports(useCache); // 生成新的报告 30 | await getLastDayStatus(useCache); 31 | refreshLastupdatedon(lastTime); // 刷新 last updated on 32 | hideLoadingMask(); // 隐藏加载动画 33 | } -------------------------------------------------------------------------------- /src/js/lastupdated.js: -------------------------------------------------------------------------------- 1 | import { fetchUrlsConfig } from "./fetchurlsconfig.js"; 2 | import { reslogs } from "./reslogs.js"; 3 | 4 | 5 | function extractLastUpdateTime(responseText) { 6 | const lines = responseText.split(/\r\n|\n/).filter(line => line !== ""); 7 | const lastLine = lines.at(-1); 8 | const lastTime = lastLine.split(",")[0]; 9 | return lastTime; 10 | } 11 | 12 | /** 13 | * 异步函数:获取所有配置的URL列表,并从中提取最新更新时间。 14 | * @param {Object} useCache - 一个用于控制是否使用缓存的对象,默认为空对象。 15 | */ 16 | export async function lastupdated(useCache = {}) { 17 | const configLines = await fetchUrlsConfig(); 18 | const urllist = configLines.map(line => line.split("=")); 19 | 20 | // 获取所有日志文件的内容 21 | const promises = urllist.map(([key]) => reslogs(key, useCache)); 22 | const responseTexts = await Promise.all(promises); 23 | 24 | // 提取每个日志文件的最后一行时间 25 | const lastTimes = responseTexts.map(extractLastUpdateTime); 26 | 27 | // 找到最后更新的时间。 28 | const lastTime = lastTimes.reduce((a, b) => { 29 | return new Date(a) > new Date(b) ? a : b; 30 | }); 31 | 32 | // 更新页面上的时间 33 | refreshLastupdatedon(lastTime); 34 | } 35 | 36 | export function refreshLastupdatedon(lastUpdateTime) { 37 | const updateTimeElement = document.getElementById("updateTime"); 38 | if (updateTimeElement) { 39 | updateTimeElement.textContent = `last updated on : ${lastUpdateTime}`; 40 | } 41 | } -------------------------------------------------------------------------------- /src/js/tooltip.js: -------------------------------------------------------------------------------- 1 | import { getStatusText } from "./utils.js"; 2 | 3 | /** 4 | * 显示提示 5 | * @param {HTMLElement} element - 元素 6 | * @param {Date} date - 日期 7 | * @param {string} color - 颜色 8 | */ 9 | export function showTooltip(element, date, color) { 10 | const toolTiptime = new Date(date); 11 | // 提取星期、年、月和日 12 | const weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][ 13 | toolTiptime.getDay() 14 | ]; 15 | const year = toolTiptime.getFullYear(); 16 | const month = ("0" + (toolTiptime.getMonth() + 1)).slice(-2); 17 | const day = ("0" + toolTiptime.getDate()).slice(-2); 18 | // 拼接成所需格式 19 | const formatTiptime = `${weekday} ${year}-${month}-${day}`; 20 | const statusContainer = element.closest(".statusContainer"); // 找到对应的 statusContainer 21 | if (!statusContainer) return; 22 | const nextElement = statusContainer.nextElementSibling; 23 | const tooltipContent = nextElement.querySelector(".span-text"); // 获取 tooltipContent 元素 24 | tooltipContent.innerText = formatTiptime + " " + getStatusText(color); 25 | tooltipContent.style.display = "block"; // 显示提示内容。 26 | } 27 | 28 | /** 29 | * 隐藏提示 30 | * @param {HTMLElement} element - 元素 31 | */ 32 | export function hideTooltip(element) { 33 | const statusContainer = element.closest(".statusContainer"); // 找到对应的 statusContainer 34 | if (!statusContainer) return; 35 | const nextElement = statusContainer.nextElementSibling; 36 | const tooltipContent = nextElement.querySelector(".span-text"); // 获取 tooltipContent 元素 37 | tooltipContent.style.display = "none"; // 隐藏提示内容 38 | } 39 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { genAllReports, getLastDayStatus } from './js/genReports.js'; // 导入生成所有报告的函数和获取最后一天状态的函数 2 | import { lastupdated } from './js/lastupdated.js'; // 导入记录最新更新时间的模块 3 | import { getclieninfo } from './js/getclieninfo.js'; // 导入获取客户端信息的函数 4 | import { scrollheader } from './js/scroll.js'; // 导入处理滚动事件以固定标题的函数 5 | import { getyear } from './js/getyear.js'; // 导入获取当前年份的函数 6 | import { reloadReports } from './js/reloadreports.js'; // 导入重新加载报告的函数 7 | import { manualreload } from './js/manualreload.js'; 8 | import { swregister } from './js/swregister.js'; 9 | import { initScrollReveal } from './js/startanimation.js'; 10 | 11 | 12 | 13 | // 配置参数 14 | export const maxDays = 60; // 日志最大展示天数 15 | export const maxHour = 12; // 报表最大小时数 16 | export const urlspath = "/src/urls.cfg"; // 配置文件路径,不带后/ 17 | export const logspath = "./logs"; // 日志文件路径,不带后/ 18 | export const reloadReportsdata = false; // 是否重新加载报告 19 | export const reloadReportstime = 2.5; // 重载报告的检测间隔时间 20 | 21 | 22 | // 主函数,异步执行一系列操作 23 | async function main() { 24 | await Promise.all([ 25 | initScrollReveal(), 26 | getclieninfo(), 27 | getyear(), 28 | lastupdated(), 29 | manualreload(), 30 | swregister(), 31 | ]); 32 | await Promise.all([ 33 | genAllReports(), 34 | lastupdated(), 35 | getLastDayStatus(), 36 | ]); 37 | await Promise.all([ 38 | scrollheader(), 39 | reloadReports(), 40 | ]); 41 | } 42 | 43 | main(); 44 | -------------------------------------------------------------------------------- /src/js/domManipulation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 模板化 3 | * @param {string} templateId - 模板 ID 4 | * @param {Object} parameters - 参数对象 5 | * @returns {HTMLElement} - 模板化后的元素。 6 | */ 7 | let cloneId = 0; 8 | export function templatize(templateId, parameters) { 9 | let clone = document.getElementById(templateId).cloneNode(true); 10 | clone.id = "template_clone_" + cloneId++; 11 | if (!parameters) { 12 | return clone; 13 | } 14 | applyTemplateSubstitutions(clone, parameters); 15 | return clone; 16 | } 17 | 18 | /** 19 | * 应用模板替换 20 | * @param {HTMLElement} node - 节点元素 21 | * @param {Object} parameters - 参数对象 22 | */ 23 | function applyTemplateSubstitutions(node, parameters) { 24 | const attributes = node.getAttributeNames(); 25 | for (var ii = 0; ii < attributes.length; ii++) { 26 | const attr = attributes[ii]; 27 | const attrVal = node.getAttribute(attr); 28 | node.setAttribute(attr, templatizeString(attrVal, parameters)); 29 | } 30 | if (node.childElementCount == 0) { 31 | node.innerText = templatizeString(node.innerText, parameters); 32 | } else { 33 | const children = Array.from(node.children); 34 | children.forEach((n) => { 35 | applyTemplateSubstitutions(n, parameters); 36 | }); 37 | } 38 | } 39 | 40 | /** 41 | * 模板字符串化 42 | * @param {string} text - 文本 43 | * @param {Object} parameters - 参数对象 44 | * @returns {string} - 字符串化后的文本 45 | */ 46 | function templatizeString(text, parameters) { 47 | if (parameters) { 48 | for (const [key, val] of Object.entries(parameters)) { 49 | text = text.replaceAll("$" + key, val); 50 | } 51 | } 52 | return text; 53 | } 54 | 55 | /** 56 | * 创建一个指定标签的元素 57 | * @param {string} tag - 标签 58 | * @param {string} className - 类名 59 | * @returns {HTMLElement} - 元素 60 | */ 61 | export function create(tag, className = "") { 62 | let element = document.createElement(tag); 63 | element.className = className; 64 | return element; 65 | } 66 | -------------------------------------------------------------------------------- /checkshell/servicecheck-local.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export TZ='Asia/Shanghai' 4 | 5 | KEYSARRAY=() 6 | URLSARRAY=() 7 | 8 | # 读取urls.cfg配置文件 9 | urlsConfig="./src/urls.cfg" 10 | while read -r line; do 11 | if [[ ${line} =~ ^\s*# ]] ; then 12 | continue 13 | fi 14 | echo "[$line] 正在检测中······" 15 | IFS='=' read -ra TOKENS <<<"$line" 16 | KEYSARRAY+=(${TOKENS[0]}) 17 | URLSARRAY+=(${TOKENS[1]}) 18 | done <"$urlsConfig" 19 | 20 | # 创建需要的文件夹 21 | mkdir -p ./logs/ 22 | mkdir -p ./tmp/ 23 | mkdir -p ./tmp/logs/ 24 | 25 | # 创建一个数组来保存所有子shell的PID 26 | pids=() 27 | 28 | # 对于每一个URL,启动一个子shell来执行检测。 29 | for ((index = 0; index < ${#KEYSARRAY[@]}; index++)); do 30 | key="${KEYSARRAY[index]}" 31 | url="${URLSARRAY[index]}" 32 | 33 | # 在子shell中执行检测 34 | ( 35 | for i in 1 2 3; do 36 | response=$(curl --write-out '%{http_code}' --silent --output /dev/null --max-time 7 "$url") 37 | if [[ "$response" =~ ^(200|201|202|301|302|307)$ ]]; then 38 | result="success" 39 | break 40 | fi 41 | result="failed" 42 | sleep 5 43 | done 44 | 45 | # 获取当前时间 46 | dateTime=$(date +'%Y-%m-%d %H:%M') 47 | 48 | # 失败的url写入临时文件,成功的url使用ping测试延迟 49 | if [[ $result == "failed" ]]; then 50 | touch ./tmp/failed_urls.lock 51 | touch ./tmp/failed_urls.log 52 | exec 9>"./tmp/failed_urls.lock" 53 | flock -x 9 54 | if ! grep -qFx "$url" ./tmp/failed_urls.log; then 55 | echo "$dateTime, $url" >>./tmp/failed_urls.log 56 | fi 57 | exec 9>&- 58 | else 59 | # 测试连接耗时 60 | connect_time_seconds=$(curl -o /dev/null -s -w "%{time_connect}\n" "$url") 61 | connect_time_ms=$(awk '{printf "%.0f\n", ($1 * 1000 + 0.5)}' <<<"$connect_time_seconds") 62 | fi 63 | 64 | # 日志数据写入log文件 65 | 66 | echo "$dateTime, $result, ${connect_time_ms:-null}" >>"./tmp/logs/${key}_report.log" 67 | # 保留1000条数据 68 | echo "$(tail -1000 ./tmp/logs/${key}_report.log)" >"./tmp/logs/${key}_report.log" 69 | ) & 70 | pids+=($!) 71 | done 72 | 73 | # 等待所有子shell完成 74 | for pid in "${pids[@]}"; do 75 | wait $pid 76 | done 77 | rm -f ./tmp/failed_urls.lock -------------------------------------------------------------------------------- /src/js/dataProcessing.js: -------------------------------------------------------------------------------- 1 | import { maxDays } from "../index.js"; 2 | /** 3 | * 规范化数据 4 | * @param {string} statusLines - 状态行字符串 5 | * @returns {Object} - 规范化后的数据 6 | */ 7 | export function normalizeData(statusLines) { 8 | const rows = statusLines.split("\n"); 9 | const dateNormalized = splitRowsByDate(rows); 10 | let relativeDateMap = {}; 11 | const now = Date.now(); 12 | for (const [key, val] of Object.entries(dateNormalized)) { 13 | if (key == "upTime") { 14 | continue; 15 | } 16 | const relDays = getRelativeDays(now, new Date(key).getTime()); 17 | relativeDateMap[relDays] = getDayAverage(val); 18 | } 19 | relativeDateMap.upTime = dateNormalized.upTime; 20 | return relativeDateMap; 21 | } 22 | 23 | /** 24 | * 获取日均数据 25 | * @param {Array} val - 数据数组 26 | * @returns {any} - 日均数据 27 | */ 28 | function getDayAverage(val) { 29 | if (!val || val.length == 0) { 30 | return null; 31 | } else { 32 | return val.reduce((a, v) => a + v) / val.length; 33 | } 34 | } 35 | 36 | /** 37 | * 获取相对天数 38 | * @param {number} dateend - 结束日期 39 | * @param {number} datestart - 开始日期 40 | * @returns {number} - 相对天数 41 | */ 42 | function getRelativeDays(dateend, datestart) { 43 | return Math.floor(Math.abs((dateend - datestart) / (24 * 3600 * 1000))); 44 | } 45 | 46 | /** 47 | * 按日期分割行 48 | * @param {Array} rows - 行数组 49 | * @returns {Object} - 按日期分割后的数据。 50 | */ 51 | function splitRowsByDate(rows) { 52 | let dateValues = {}; 53 | let sum = 0, 54 | count = 0; 55 | for (var ii = 0; ii < rows.length; ii++) { 56 | const row = rows[ii]; 57 | if (!row) { 58 | continue; 59 | } 60 | const [dateTimeStr, resultStr] = row.split(",", 2); 61 | const dateTime = new Date(Date.parse(dateTimeStr.replace(/-/g, "/"))); 62 | const dateStr = dateTime.toDateString(); 63 | let resultArray = dateValues[dateStr]; 64 | if (!resultArray) { 65 | resultArray = []; 66 | dateValues[dateStr] = resultArray; 67 | if (dateValues.length > maxDays) { 68 | break; 69 | } 70 | } 71 | let result = 0; 72 | if (resultStr && resultStr.trim() == "success") { 73 | result = 1; 74 | } 75 | sum += result; 76 | count++; 77 | resultArray.push(result); 78 | } 79 | const upTime = count ? ((sum / count) * 100).toFixed(2) + "%" : "--%"; 80 | dateValues.upTime = upTime; 81 | return dateValues; 82 | } 83 | -------------------------------------------------------------------------------- /checkshell/actions-local.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export TZ='Asia/Shanghai' 4 | 5 | # 检查是否有仓库。 6 | if [ ! -d "./knloop-service-status/" ]; then 7 | git clone git@github.com:shadowqcom/knloop-service-status.git 8 | fi 9 | 10 | cd ./knloop-service-status/ 11 | git checkout -b page origin/page > /dev/null 2>&1 12 | git pull origin page > /dev/null 2>&1 13 | sudo bash ./checkshell/servicecheck-local.sh 14 | 15 | # 如果./tmp/logs文件夹为空 16 | if [ ! -d "./tmp/logs" ]; then 17 | echo "没有检测到日志文件,终止后续动作。" 18 | exit 0 19 | fi 20 | 21 | KEYSARRAY=() 22 | 23 | urlsConfig="./src/urls.cfg" 24 | 25 | # 从配置文件中读取键 26 | mapfile -t KEYSARRAY < <(grep -v '^#' "$urlsConfig" | cut -d '=' -f 1) 27 | 28 | random_number=$((RANDOM % ${#KEYSARRAY[@]})) 29 | key=${KEYSARRAY[$random_number]} 30 | first_line=$(head -n 1 "./tmp/logs/${key}_report.log") 31 | timestamp=$(echo "$first_line" | awk '{print $1 " " $2}') 32 | statrtime="${timestamp%,}" 33 | 34 | # 获取当前时间 35 | dateTime=$(date +'%Y-%m-%d %H:%M') 36 | 37 | # 将时间戳转换为 Unix 时间戳(秒) 38 | startTime=$(date -d "$statrtime" +%s) 39 | currentTime=$(date -d "$dateTime" +%s) 40 | 41 | # 计算时间差 42 | timeDifference=$((currentTime - startTime)) 43 | minute=$((timeDifference / 60)) 44 | 45 | # 每180分钟提交一次 46 | if [ $minute -lt 180 ]; then 47 | echo "时间间隔太短,暂不提交。" 48 | exit 0 49 | fi 50 | 51 | # 拉取最新代码 52 | git pull origin page 53 | 54 | # 整理和排序 确保文件按照时间顺序排列 55 | for key in "${KEYSARRAY[@]}"; do 56 | # 提取最后30行并保存到临时文件 57 | tail -n 30 "./logs/${key}_report.log" > "./tmp/logs/${key}_report.log.tmp" 58 | 59 | # 删除原文件的末尾 30 行 60 | head -n -30 "./logs/${key}_report.log" > "./logs/${key}_report.log.new" 61 | mv "./logs/${key}_report.log.new" "./logs/${key}_report.log" 62 | 63 | # 将临时文件中的行合并到临时日志文件 64 | cat "./tmp/logs/${key}_report.log.tmp" >> "./tmp/logs/${key}_report.log" 65 | 66 | # 对临时日志文件进行排序 67 | sort -t ',' -k1,1 -k2,2n "./tmp/logs/${key}_report.log" > "./tmp/logs/${key}_report.log.sorted" 68 | 69 | # 将排序后的行追加到主日志文件中 70 | cat "./tmp/logs/${key}_report.log.sorted" >> "./logs/${key}_report.log" 71 | 72 | # 清理临时文件 73 | rm -f "./tmp/logs/${key}_report.log.tmp" 74 | rm -f "./logs/${key}_report.log.new" 75 | rm -f "./tmp/logs/${key}_report.log.sorted" 76 | done 77 | 78 | # 配置用户信息并提交到page分支 79 | git config --local user.name 'Hongkong Actions' 80 | git config --local user.email 'Hongkongactions@knloop.com' 81 | git add -A --force ./logs/ 82 | git commit -m '🆙 [Hongkong Actions] Update service status logs' 83 | git push origin page 84 | rm -f ./tmp/logs/* -------------------------------------------------------------------------------- /src/js/reloadreports.js: -------------------------------------------------------------------------------- 1 | import { reloadReportsdata, reloadReportstime } from "../index.js"; 2 | import { reslogs } from "./reslogs.js"; 3 | import { fetchUrlsConfig } from "./fetchurlsconfig.js"; 4 | import { genAllReports, getLastDayStatus } from "./genReports.js"; 5 | import { refreshLastupdatedon } from "./lastupdated.js"; 6 | 7 | const useCache = { cache: 'reload' }; // 不使用缓存 8 | let startTime; 9 | async function checkAndReloadReports() { 10 | startTime = await getlastTime(); // 初始化全局变量 11 | const interval = reloadReportstime * 60 * 1000; // 分钟转换为毫秒 12 | 13 | // 使用setInterval来周期性地执行 14 | setInterval(async function () { 15 | try { 16 | const lastTime = await getlastTime(); 17 | if (startTime >= lastTime) { 18 | return; // 如果时间没有变化,则跳过此次循环 19 | } 20 | 21 | // 数据有更新则重新加载日志数据 22 | showLoadingMask(); // 显示加载动画 23 | clearReports(); // 清理旧的报告 24 | await genAllReports(useCache); // 生成新的报告 25 | await getLastDayStatus(useCache); 26 | refreshLastupdatedon(lastTime); // 刷新 last updated on 27 | hideLoadingMask(); // 隐藏加载动画 28 | 29 | startTime = lastTime; // 重置开始时间 30 | 31 | } catch (error) { 32 | console.error("重载日志数据失败:", error); 33 | } 34 | }, interval); 35 | } 36 | 37 | // 获取随机一个服务的最后一行时间 38 | export async function getlastTime() { 39 | const configLines = await fetchUrlsConfig(); 40 | 41 | const randomIndex = Math.floor(Math.random() * configLines.length); // 从配置行中随机选择一行 42 | const randomLine = configLines[randomIndex]; 43 | 44 | const [key] = randomLine.split("="); 45 | const response = await reslogs(key, useCache); 46 | const lastlines = response.split(/\r\n|\n/).filter((entry) => entry !== ""); 47 | const lastTime = lastlines.at(-1).split(",")[0]; 48 | return lastTime; 49 | } 50 | 51 | // 清理旧的报告 52 | export function clearReports() { 53 | const reportsElement = document.getElementById("reports"); 54 | while (reportsElement.firstChild) { 55 | reportsElement.removeChild(reportsElement.firstChild); 56 | } 57 | const img = document.querySelector("#statusImg"); 58 | img.src = "./public/check/nodata.svg"; 59 | img.alt = "No Data"; 60 | img.classList.add('icobeat'); // 跳动。 61 | 62 | refreshLastupdatedon("Loading..."); // 刷新 last updated on 63 | 64 | } 65 | 66 | 67 | // 显示加载动画 68 | export function showLoadingMask() { 69 | document.getElementById("loading-mask").classList.remove("hidden"); 70 | } 71 | // 隐藏加载动画 72 | export function hideLoadingMask() { 73 | document.getElementById("loading-mask").classList.add("hidden"); 74 | } 75 | 76 | export async function reloadReports() { 77 | if (reloadReportsdata) { 78 | await checkAndReloadReports(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/js/utils.js: -------------------------------------------------------------------------------- 1 | import { templatize } from "./domManipulation.js"; 2 | import { showTooltip, hideTooltip } from "./tooltip.js"; 3 | import { maxDays } from "../index.js"; 4 | /** 5 | * 获取颜色 6 | * @param {any} uptimeVal - 运行时间值 7 | * @returns {string} - 颜色字符串 8 | */ 9 | export function getColor(uptimeVal) { 10 | return uptimeVal == null 11 | ? "nodata" 12 | : uptimeVal == 1 13 | ? "success" 14 | : uptimeVal < 0.3 15 | ? "failure" 16 | : "partial"; 17 | } 18 | 19 | /** 20 | * 获取状态文本 21 | * @param {string} color - 颜色 22 | * @returns {string} - 状态文本 23 | */ 24 | export function getStatusText(color) { 25 | return color == "nodata" 26 | ? "No Data" 27 | : color == "success" 28 | ? "up" 29 | : color == "failure" 30 | ? "Down" 31 | : color == "partial" 32 | ? "Degraded" 33 | : "Unknown"; 34 | } 35 | 36 | /** 37 | * 构建状态流 38 | * @param {string} url - URL 39 | * @param {Object} uptimeData - 运行时间数据 40 | */ 41 | export function constructStatusStream(key, url, uptimeData) { 42 | let streamContainer = templatize("statusStreamContainerTemplate"); 43 | for (var ii = maxDays - 1; ii >= 0; ii--) { 44 | let line = constructStatusLine(ii, uptimeData[ii]); 45 | streamContainer.appendChild(line); 46 | } 47 | const lastSet = uptimeData[0]; 48 | const color = getColor(lastSet); 49 | const statusText = getStatusText(color); 50 | 51 | // 创建 img 元素 52 | const img = document.createElement('img'); 53 | img.className = 'statusIcon'; 54 | img.alt = `status${color}`; 55 | img.src = `./public/check/${color}.svg`; 56 | 57 | const container = templatize("statusContainerTemplate", { 58 | title: key, 59 | url: url, 60 | statusblock: statusText, 61 | upTime: uptimeData.upTime, 62 | }); 63 | 64 | // 将img元素插入到statusTitle之前。 65 | const parent = container.querySelector('#statusTitle').parentNode; 66 | parent.insertBefore(img, parent.querySelector('#statusTitle')); 67 | 68 | container.appendChild(streamContainer); 69 | 70 | // console.log(container) 71 | 72 | return container; 73 | } 74 | 75 | /** 76 | * 构建状态行 77 | * @param {number} relDay - 相对天数 78 | * @param {any} upTimeArray - 运行时间数组 79 | * @returns {HTMLElement} - 状态行元素 80 | */ 81 | function constructStatusLine(relDay, upTimeArray) { 82 | let date = new Date(); 83 | date.setDate(date.getDate() - relDay); 84 | return constructStatusSquare(date, upTimeArray); 85 | } 86 | 87 | function constructStatusSquare(date, uptimeVal) { 88 | const color = getColor(uptimeVal); 89 | let square = templatize("statusSquareTemplate", { 90 | color: color, 91 | }); 92 | const show = () => { 93 | showTooltip(square, date, color); 94 | }; 95 | const hide = () => { 96 | hideTooltip(square); 97 | }; 98 | square.addEventListener("mouseover", show); 99 | square.addEventListener("mousedown", show); 100 | square.addEventListener("mouseout", hide); 101 | return square; 102 | } 103 | -------------------------------------------------------------------------------- /checkshell/servicecheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export TZ='Asia/Shanghai' 4 | 5 | KEYSARRAY=() 6 | URLSARRAY=() 7 | 8 | # 读取urls.cfg配置文件 9 | urlsConfig="./src/urls.cfg" 10 | while read -r line; do 11 | if [[ ${line} =~ ^\s*# ]] ; then 12 | continue 13 | fi 14 | echo "[$line] 正在检测中······" 15 | IFS='=' read -ra TOKENS <<<"$line" 16 | KEYSARRAY+=(${TOKENS[0]}) 17 | URLSARRAY+=(${TOKENS[1]}) 18 | done <"$urlsConfig" 19 | 20 | # 创建需要的文件夹 21 | mkdir -p ./logs 22 | mkdir -p ./tmp 23 | 24 | # 创建一个数组来保存所有子shell的PID 25 | pids=() 26 | 27 | # 对于每一个URL,启动一个子shell来执行检测 28 | for ((index = 0; index < ${#KEYSARRAY[@]}; index++)); do 29 | key="${KEYSARRAY[index]}" 30 | url="${URLSARRAY[index]}" 31 | 32 | # 在子shell中执行检测 33 | ( 34 | for i in 1 2 3; do 35 | response=$(curl --write-out '%{http_code}' --silent --output /dev/null --max-time 7 "$url") 36 | if [[ "$response" =~ ^(200|201|202|301|302|307)$ ]]; then 37 | result="success" 38 | break 39 | fi 40 | result="failed" 41 | sleep 5 42 | done 43 | 44 | # 成功的url使用ping测试延迟。 45 | if [[ $result == "success" ]]; then 46 | # 通过curl测试连接耗时 47 | connect_time_seconds=$(curl -o /dev/null -s -w "%{time_connect}\n" "$url") 48 | connect_time_ms=$(awk '{printf "%.0f\n", ($1 * 1000 + 0.5)}' <<<"$connect_time_seconds") 49 | fi 50 | 51 | # 日志数据写入log文件 52 | dateTime=$(date +'%Y-%m-%d %H:%M') 53 | echo "$dateTime, $result, ${connect_time_ms:-null}" >> "./logs/${key}_report.log" 54 | # 保留30000条数据 55 | echo "$(tail -30000 ./logs/${key}_report.log)" > "./logs/${key}_report.log" 56 | ) & 57 | pids+=($!) 58 | done 59 | 60 | # 等待所有子shell完成 61 | for pid in "${pids[@]}"; do 62 | wait $pid 63 | done 64 | 65 | # 读取webhook.cfg配置,用一个数组webhookconfig存储配置项 66 | declare -A webhookconfig 67 | while IFS='=' read -r key value; do 68 | # 移除键和值两侧的空白字符 69 | key=$(echo "$key" | xargs) 70 | value=$(echo "$value" | xargs) 71 | # 存储键值对 72 | webhookconfig["$key"]="$value" 73 | done <./src/webhook.cfg 74 | 75 | 76 | # 如果./tmp/failed_urls.log不存在 77 | if [ ! -f "./tmp/failed_urls.log" ]; then 78 | echo "没有失败的url" 79 | exit 0 80 | fi 81 | 82 | # 构建Markdown消息 83 | failedUrlsMessage="" 84 | while IFS= read -r line; do 85 | if [ -n "$failedUrlsMessage" ]; then 86 | failedUrlsMessage+="\n" 87 | fi 88 | failedUrlsMessage+="$line" 89 | done <./tmp/failed_urls.log 90 | 91 | # 检查是否开启推送,如果开启了推送并且有失败的url 则推送企业微信 92 | if [[ "${webhookconfig["push"]}" == "true" ]] && [ -n "$failedUrlsMessage" ]; then 93 | echo "**********************************************" 94 | echo "检测完成,开始推送企业微信" 95 | MessageTime=$(date +'%Y-%m-%d %H:%M') 96 | curl "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=$WEBHOOK_KEY" \ 97 | -H 'Content-Type: application/json' \ 98 | -d '{ 99 | "msgtype": "markdown", 100 | "markdown": { 101 | "content": "### Service Down\n > '"$MessageTime"'\n > 以下 url/api 请求失败:\n\n'"$failedUrlsMessage"'" 102 | } 103 | }' 104 | fi -------------------------------------------------------------------------------- /src/js/sw.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('install', function (event) { 2 | event.waitUntil( 3 | caches.open('v1').then(function (cache) { 4 | return cache.addAll([ 5 | '/', 6 | '/index.html', 7 | '/src/index.js', 8 | '/src/urls.cfg', 9 | '/src/index.css', 10 | '/src/js/chart.umd.js', 11 | '/src/js/dataProcessing.js', 12 | '/src/js/domManipulation.js', 13 | '/src/js/fetchurlsconfig.js', 14 | '/src/js/genReports.js', 15 | '/src/js/getclieninfo.js', 16 | '/src/js/getyear.js', 17 | '/src/js/lastupdated.js', 18 | '/src/js/reloadreports.js', 19 | '/src/js/reslogs.js', 20 | '/src/js/scroll.js', 21 | '/src/js/timelapsechart.js', 22 | '/src/js/tooltip.js', 23 | '/src/js/utils.js', 24 | '/src/js/scrollreveal.min.js', 25 | '/src/js/startanimation.js', 26 | '/public/favicon.ico', 27 | '/public/logo.svg', 28 | '/public/logo.png', 29 | '/public/check/partial.svg', 30 | '/public/check/failure.svg', 31 | '/public/check/nodata.svg', 32 | '/public/check/success.svg', 33 | 34 | // 本地日志 35 | '/logs/Web_report.log', 36 | '/logs/Dev_report.log', 37 | '/logs/Mojocn_report.log', 38 | '/logs/Mojoo_report.log', 39 | '/logs/ShadowQ_report.log', 40 | '/logs/Google_report.log', 41 | 42 | // 网络资源 43 | 'https://raw.github.knloop.com/knloop-service-status/page/logs/Web_report.log', 44 | 'https://raw.github.knloop.com/knloop-service-status/page/logs/Dev_report.log', 45 | 'https://raw.github.knloop.com/knloop-service-status/page/logs/Mojocn_report.log', 46 | 'https://raw.github.knloop.com/knloop-service-status/page/logs/Mojoo_report.log', 47 | 'https://raw.github.knloop.com/knloop-service-status/page/logs/ShadowQ_report.log', 48 | 'https://raw.github.knloop.com/knloop-service-status/page/logs/Google_report.log' 49 | ]); 50 | }) 51 | ); 52 | }); 53 | 54 | const putInCache = async (request, response) => { 55 | const cache = await caches.open("v1"); 56 | await cache.put(request, response); 57 | }; 58 | 59 | const cacheFirst = async ({ request, fallbackUrl }) => { 60 | // 首先尝试从缓存中获取资源。 61 | const responseFromCache = await caches.match(request); 62 | if (responseFromCache) { 63 | return responseFromCache; 64 | } 65 | 66 | // 如果在缓存中找不到响应,则尝试通过网络获取资源。 67 | try { 68 | const responseFromNetwork = await fetch(request); 69 | putInCache(request, responseFromNetwork.clone()); 70 | return responseFromNetwork; 71 | } catch (error) { 72 | const fallbackResponse = await caches.match(fallbackUrl); 73 | if (fallbackResponse) { 74 | return fallbackResponse; 75 | } 76 | return new Response("网络错误", { 77 | status: 408, 78 | headers: { "Content-Type": "text/plain" }, 79 | }); 80 | } 81 | }; 82 | 83 | self.addEventListener("fetch", (event) => { 84 | event.respondWith( 85 | cacheFirst({ 86 | request: event.request, 87 | fallbackUrl: "/index.html", 88 | }), 89 | ); 90 | }); 91 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | knloop status 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | knloop sta 22 | 23 | 24 | 25 | 27 | Github 28 | 29 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | knloop service status 43 | last updated on: Loading... 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | $title 60 | 61 | 62 | $upTime Uptime within 60 days 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | Data is Loading ... 73 | 74 | 75 | 76 | 77 | 78 | UA : $uag 79 | 80 | 81 | 90 | 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /src/js/genReports.js: -------------------------------------------------------------------------------- 1 | import { reslogs } from "./reslogs.js"; 2 | import { updateChart } from "./timelapsechart.js"; 3 | import { getColor, constructStatusStream } from "./utils.js"; 4 | import { normalizeData } from "./dataProcessing.js"; 5 | import { create } from "./domManipulation.js"; 6 | import { scrolltoright } from "./scroll.js"; 7 | import { fetchUrlsConfig } from "./fetchurlsconfig.js"; 8 | import { hideLoadingMask } from "./reloadreports.js"; 9 | 10 | /** 11 | * 异步函数:根据 urls.cfg 文件,生成所有报告。 12 | * @param {string} urlspath - 配置文件的路径,其中包含需要生成报告的URL列表。 13 | */ 14 | export async function genAllReports(useCache = {}) { 15 | try { 16 | const configLines = await fetchUrlsConfig(); 17 | for (let ii = 0; ii < configLines.length; ii++) { 18 | const configLine = configLines[ii]; 19 | const [key, url] = configLine.split("="); 20 | await genReportLog(document.getElementById("reports"), key, url, useCache); 21 | } 22 | 23 | } catch (error) { 24 | console.error("Error genAllReports :", error); 25 | } 26 | 27 | hideLoadingMask(); // 隐藏loading-mask 28 | scrolltoright(); // 滚动到最右侧 29 | } 30 | 31 | 32 | /** 33 | * 异步生成报告日志。 34 | * @param {HTMLElement} container - 用于装载报告日志的容器元素。 35 | * @param {string} key - 报告日志的唯一标识键。 36 | * @param {string} url - 相关 URL,用于报告中显示。 37 | * @param {string} logspath - 日志文件的路径。 38 | */ 39 | async function genReportLog(container, key, url, useCache = {}) { 40 | let statusLines = await reslogs(key, useCache); 41 | 42 | const normalized = normalizeData(statusLines); 43 | const statusStream = constructStatusStream(key, url, normalized); 44 | container.appendChild(statusStream); 45 | // 创建一个 div 来包裹 span 标签 46 | const divWrapper = create("div"); 47 | divWrapper.classList.add("span-wrapper"); // 添加一个类以便在 CSS 中定位这个 div 48 | divWrapper.id = "status-prompt"; // 设置 div 的 ID 49 | // 创建并添加两个 span 标签到 divWrapper 中 50 | const spanLeft = create("span", "span-title"); 51 | spanLeft.textContent = "响应时间(ms)"; 52 | spanLeft.classList.add("align-left"); 53 | const spanRight = create("span", "span-text"); 54 | spanRight.classList.add("align-right"); 55 | divWrapper.appendChild(spanLeft); 56 | divWrapper.appendChild(spanRight); 57 | // 将包含两个 span 的 div 添加到 container 中 58 | container.appendChild(divWrapper); 59 | const canvas = create("canvas", "chart"); 60 | canvas.id = "chart_clone_" + key++; 61 | container.appendChild(canvas); 62 | updateChart(canvas, statusLines); 63 | } 64 | 65 | 66 | // 所有服务当天整体状态评估 67 | export async function getLastDayStatus(useCache = {}) { 68 | const configLines = await fetchUrlsConfig(); 69 | const statusTexts = []; // 存储 statusText 的数组 70 | for (let ii = 0; ii < configLines.length; ii++) { 71 | const configLine = configLines[ii]; 72 | const [key] = configLine.split("="); 73 | 74 | // 根据条件确定是否使用缓存 75 | let statusLines = await reslogs(key, useCache); 76 | 77 | const normalized = normalizeData(statusLines); 78 | // 获取最后一天的状态 79 | const lastDayStatus = normalized[0]; 80 | const statusText = getColor(lastDayStatus); // nodata success failure 81 | statusTexts.push(statusText); // 将 statusText 存入数组。 82 | } 83 | 84 | let successCount = 0, failureCount = 0, nodataCount = 0; 85 | 86 | for (const item of statusTexts) { 87 | if (item === 'success') { 88 | successCount++; 89 | } else if (item === 'failure') { 90 | failureCount++; 91 | } else if (item === 'nodata') { 92 | nodataCount++; 93 | } 94 | } 95 | 96 | const totalCount = statusTexts.length; // 总服务数 97 | const failureThreshold = totalCount * 0.2; // 有效服务 Down 20% 即整体报告为Down 98 | const nodateThreshold = totalCount * 0.5; // 有效服务 No Data 50% 即整体报告为No Data 99 | 100 | const conditions = [ 101 | { condition: successCount === totalCount, src: './public/check/success.svg', alt: 'UP' }, 102 | { condition: nodataCount === totalCount, src: './public/check/nodata.svg', alt: 'No data' }, 103 | { condition: failureCount >= failureThreshold || nodataCount >= nodateThreshold, src: './public/check/failure.svg', alt: 'Down' }, 104 | { condition: true, src: './public/check/partial.svg', alt: 'Degraded' } 105 | ]; 106 | 107 | const img = document.querySelector("#statusImg"); 108 | 109 | for (const { condition, src, alt } of conditions) { 110 | if (condition) { 111 | img.src = src; 112 | img.alt = alt; 113 | img.classList.remove('icobeat'); 114 | break; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🆙 knloop service status 2 | 3 | **轻量、开源、零依赖的服务状态监控。** 4 | - 功能特点 5 | - 零依赖,无服务器依赖,纯静态页面。 6 | - 支持企业微信和wehook 机器人推送。 7 | - 支持自建服务部署页面和监控节点。 8 | - 本地部署可以实现秒级监控。 9 | - 占用资源少,监控节点仅使在bash环境使用curl。 10 | - GitHub Actions 运行监控脚本 11 | - 最低每 5 分钟,workflows可以利用脚本批量访问你需要监控的网站并获得详细信息 12 | - 手动或者在有新的页面变化的时候才部署GitHub Pages 13 | - 当开启通知功能,当服务状态不可用时,会推送到企业微信或调用指定的webhook。 14 | - GitHub Pages 发布静态页面 15 | - 简洁、美观自适应页面,支持 PWA 16 | - 零依赖,无需任何构建工具 17 | - 使用 GitHub RWA 从仓库访问最新数据 18 | 19 | 20 | ## 👀 查看效果 21 | 22 | 在线演示 : [status.knloop.com](https://status.knloop.com) 23 | 24 | 截图展示 : 25 |  26 | 27 | ## ⚙️ 部署说明 28 | 29 | ## 方案一、GitHub page + GitHub Actions 最简单方式部署(推荐) 30 | ### 1. [Fork](https://github.com/shadowqcom/knloop-service-status/fork) 本项目 [knloop service status](https://github.com/shadowqcom/knloop-service-status/fork). 31 | 32 | ### 2. 按照下面格式修改 `urls.cfg` 文件中的内容。 33 | 34 | ```cfg 35 | Web=https://knloop.com 36 | Google=https://google.com 37 | ``` 38 | 39 | ### 3. 修改个性化信息 40 | 41 | ```html 42 | 43 | knloop sta 44 | knloop service status 45 | ``` 46 | 47 | ### 4. 修改全局配置参数(可选) 48 | 49 | `./src/js/index.js` 50 | 51 | ```js 52 | // 配置参数 53 | export const maxDays = 60; // 日志最大展示天数 54 | export const maxHour = 12; // 报表最大小时数 55 | export const urlspath = "/src/urls.cfg"; // 配置文件路径,不带后/ 56 | export const logspath = "./page/logs"; // 日志文件路径,不带后/ 57 | export const reloadReportsdata = true; // 是否重新加载报告 58 | export const reloadReportstime = 2.5; // 重载报告的检测间隔时间 59 | ``` 60 | 进阶操作:可以把logspath设置为 `https://raw.githubusercontent.com/用户名/仓库名/分支名/logs` 这样可以访问仓库内最新的log文件,而无需等待重新部署页面。弊端就是raw.githubusercontent.com域名在大陆地区访问质量不高。 61 | 62 | ### 5. 配置 GitHub Pages 和 actions权限 63 | 64 | - 转到 `settings --> pages` , 65 | 66 | - `Build and deployment` 设置为 Deploy from a branch , 67 | 68 | - `Branch` 设置为 main, 69 | 70 | - `Custom domain` 配置你的自定义域名, 71 | 72 | - `Enforce HTTPS` 强制https 建议勾选上。 73 | 74 | 除此之外 还需要配置 actions 对仓库的读写权限,否则检测的结果无法写回仓库。 75 | - 转到 `settings --> actions ---> General` , 76 | - `Workflow permissions` 设置为 Read and write permissions . 77 | 78 |  79 | 80 | 进阶操作:由于每次提交日志都会触发页面部署,造成不必要的免费额度浪费,所以增加了一个 `deploy-status-pages.yml`文件 可以手动触发 也可以通过提交触发,根据需求灵活配置。 81 | 关于GitHub actions的相关配置就不多阐述,请参考官方文件。 82 | 83 | ### 6. 配置自动任务 84 | 修改 `service-status-check.yml` 里面的相关配置 85 | ```conf 86 | - cron: "*/25 * * * *" # 定时任务间隔 87 | ref: page # 默认分支,一般填main 88 | git push origin page # 提交到哪个分支,一般也是main 89 | ``` 90 | 然后就是git信息修改,user.name之类的 按照自己的需求修改。 91 | 92 | ### 7. 配置 WECHAT_WEBHOOK_KEY (可选) 93 | 94 | 用作推送失败的url到企业微信机器人。 95 | 96 | - 转到 `settings --> Secrets and variables --> Actions` , 97 | - 新建一个 `Repository secrets` , 98 | - `Name` 填 `WECHAT_WEBHOOK_KEY` , 99 | - `Secret` 填写你的企业微信机器人 Webhook地址 key= 后面的值。 100 | 101 | 102 | ## 方案二、自建服务器本地部署 103 | ### 1、前置条件 104 | 1.1、需要在目标机器上安装git 105 | 1.2、需要配置git密钥,有权限提交仓库。 106 | 1.3、需要支持bash环境,一般Linux发行版都内置了。 107 | 108 | ### 2、必要的配置 109 | 2.1、把 `checkshell/fuse.sh`复制到你个有权限的目录下 110 | 修改fuse.sh里面的仓库地址和分支名为你自己的仓库和默认分支。 111 | 112 | 2.2、修改在仓库`actions-local.sh`里面的uesr.name和user.email ,最好也修改一下commit的消息。 113 | 2.3、设置自动任务,定时运行`fuse.sh`: 114 | 115 | ```sh 116 | crontab -e 117 | ``` 118 | 119 | ```sh 120 | */2 * * * * /bin/bash /path/to/fuse.sh > /dev/null 2>&1 121 | ``` 122 | 123 | ### 3、发布静态页面 124 | 3.1、你仍然可以使用github pages发布静态页面,但需要更加频繁的更新日志文件的话 就会产生过多提交记录,慎用。 125 | 126 | 3.2、使用nginx部署静态页面, root指向fuse.sh clone下载的本地仓库文件根目录。 127 | 下面试一个简单的例子: 128 | ```conf 129 | server { 130 | listen 80; 131 | root /path/to/page; 132 | server_name yourdomain.com; 133 | location / { 134 | index index.html; 135 | } 136 | } 137 | ``` 138 | 139 | ## 🛠️ 工作原理 140 | 141 | 1、默认情况下该项目使用 `GitHub Actions` 根据设定的时间间隔运行 shell 脚本 `servicecheck.sh` ,该脚本读取 `urls.cfg` 配置,使用 curl 测试每个符合要求的 url ,将得到的结果(时间、状态、延迟ms)写入`.log`日志文件。 142 | 143 | 2、通过 `GitHub Actions` 执行 `git push` 提交到本仓库。 如果你是自己服务器或者本地运行监测脚本,则是在 `actions-local.sh` 中执行 `git push` 。 144 | 145 | 3、使用 GitHub Pages 发布0依赖、纯html/js实现的静态页面,在 `index.html` 中使用 JavaScript 动态提取日志文件,经过处理和计算后把Uptime和延迟数据报表以易于阅读的方式展示出来。 146 | 147 | ## ⏱️ 功能规划(TODO) 148 | 149 | - [x] 鼠标悬浮展示详情 150 | - [x] 移动端适配 151 | - [x] 在workflows提交log文件 152 | - [x] 企业微信推送(理论上也支持其他Webhook地址) 153 | - [x] 小屏幕可左右滑动状态条 154 | - [x] 展示日志最后更新时间 155 | - [x] 延迟ms数检测 156 | - [x] 延迟曲线图 157 | - [x] 统计图中没有数据的地方显示虚线 158 | - [x] 所有服务当天总体评估状态 159 | - [x] 自动重载报表和日志数据 160 | - [ ] SSL状态检测 161 | - [ ] SSH 检测 162 | - [ ] 钉钉/飞书/邮箱/telegram 通知 163 | - [ ] 邮箱通知 164 | 165 | ## 🐞 已知问题 166 | 167 | - ~~统计图数据可能延迟~~ 168 | - ~~统计数据最新的一个小时可能不准确~~ 169 | - 手机浏览器打开页面后如果浏览器在后台运行一段时间,重新打开浏览器则报表消失。 170 | 171 | ## 💡 灵感来自 172 | 173 | - [kener](https://github.com/rajnandan1/kener) 174 | - [statuspage](https://github.com/statsig-io/statuspage/) 175 | - [UptimeFlare](https://github.com/lyc8503/UptimeFlare) 176 | - [statusfy](https://github.com/juliomrqz/statusfy) 177 | - [uptime-status](https://github.com/yb/uptime-status) -------------------------------------------------------------------------------- /src/js/timelapsechart.js: -------------------------------------------------------------------------------- 1 | import { maxHour } from "../index.js"; 2 | 3 | 4 | /** 5 | * 异步函数:更新图表数据 6 | * 7 | * 本函数用于根据给定的日志数据更新图表。它首先确定当前小时的开始时间, 8 | * 然后从日志数据中筛选出该小时内的数据点,最后更新图表以反映这些数据。 9 | * 10 | * @param {HTMLElement} el - 图表元素的引用,用于更新图表的DOM元素。 11 | * @param {Array} logData - 包含日志数据的数组,每个元素代表一个数据点。 12 | * @returns {void} 13 | */ 14 | 15 | export async function updateChart(el, logData) { 16 | try { 17 | const now = new Date(); 18 | const startOfCurrentHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0); 19 | const twelveHoursAgo = new Date(startOfCurrentHour.getTime() - maxHour * 60 * 60 * 1000); 20 | 21 | // 分割日志数据为单独的条目。 22 | const logEntries = logData.split(/\r\n|\n/).filter((entry) => entry !== ""); 23 | 24 | // 初始化小时数据对象。 25 | const hourlyData = new Map(); // 使用Map以获得更好的性能 26 | 27 | // 遍历日志条目,提取并汇总每小时的数据。 28 | logEntries.forEach((entry) => { 29 | const parts = entry.split(", ").filter((entry) => entry !== "null"); 30 | if (parts.length >= 3) { 31 | const timeStr = parts[0]; 32 | const delay = parseInt(parts[2], 10); 33 | const date = new Date(timeStr); 34 | 35 | // 如果日期在过去12小时内,累加从现在到过去12小时的数据。 36 | if (date >= twelveHoursAgo && date <= now) { 37 | const hourKey = `${date.getHours()}:00`; 38 | if (!hourlyData.has(hourKey)) { 39 | hourlyData.set(hourKey, { total: 0, count: 0, values: [] }); 40 | } 41 | hourlyData.get(hourKey).total += delay; 42 | hourlyData.get(hourKey).count++; 43 | hourlyData.get(hourKey).values.push(delay); 44 | } 45 | } 46 | }); 47 | 48 | // 初始化图表标签、平均数据和中位数数据数组。 49 | const labels = []; 50 | const averageData = []; 51 | const medianData = []; 52 | 53 | // 遍历过去12小时,计算每小时的平均值和中位数。 54 | let currentHour = new Date(startOfCurrentHour); 55 | for (let i = 0; i <= maxHour; i++) { 56 | const hourKey = `${currentHour.getHours()}:00`; 57 | const hourlyDatum = hourlyData.get(hourKey) || { total: 0, count: 0, values: [] }; 58 | 59 | const average = 60 | hourlyDatum.count > 0 61 | ? hourlyDatum.total / hourlyDatum.count 62 | : null; 63 | 64 | const median = 65 | hourlyDatum.values.length > 0 66 | ? calculateMedian(hourlyDatum.values) 67 | : null; 68 | 69 | labels.push(hourKey); 70 | averageData.push(average); 71 | medianData.push(median); 72 | 73 | currentHour.setHours(currentHour.getHours() - 1); 74 | } 75 | 76 | // 反转数组,因为Chart.js默认从最新的小时开始绘制。 77 | labels.reverse(); 78 | averageData.reverse(); 79 | medianData.reverse(); 80 | 81 | // 合并平均数和中位数,过滤掉NaN值,然后根据最大值来决定是否设置y轴的最大值 82 | const combinedData = averageData 83 | .concat(medianData) 84 | .filter((value) => !isNaN(value)); 85 | let yMaxConfig = {}; 86 | if (combinedData.length === 0 || Math.max(...combinedData) <= 14) { 87 | yMaxConfig.max = 15; 88 | } 89 | 90 | // 获取图表上下文并创建新的Chart实例。 91 | const ctx = el.getContext("2d"); 92 | 93 | // 如果图表实例已经存在,则更新它,而不是创建一个新的实例。 94 | let chartInstance = el.chartInstance; 95 | if (!chartInstance) { 96 | chartInstance = new Chart(ctx, { 97 | type: "line", 98 | data: { 99 | labels, 100 | datasets: [ 101 | { 102 | label: "平均值", 103 | data: averageData, 104 | fill: false, 105 | borderColor: "#4bc0c0", 106 | tension: 0.4, 107 | segment: { 108 | borderDash: (ctx) => skipped(ctx, [4, 6]), 109 | }, 110 | spanGaps: true, 111 | }, 112 | { 113 | label: "中位数", 114 | data: medianData, 115 | fill: false, 116 | borderColor: "#ff6384", 117 | tension: 0.4, 118 | segment: { 119 | borderDash: (ctx) => skipped(ctx, [4, 6]), 120 | }, 121 | spanGaps: true, 122 | }, 123 | ], 124 | }, 125 | options: { 126 | plugins: { 127 | legend: { 128 | display: false, // 显示图例 129 | }, 130 | }, 131 | scales: { 132 | x: { 133 | title: { 134 | display: false, 135 | }, 136 | ticks: { 137 | autoSkip: true, // 确保每个点都被标记 138 | maxRotation: 65, // 设置最大旋转角度 139 | minRotation: 0, // 设置最小旋转角度 140 | }, 141 | }, 142 | y: { 143 | title: { 144 | display: false, 145 | }, 146 | beginAtZero: true, 147 | ...yMaxConfig, // 使用yMaxConfig来有条件地设置max 148 | }, 149 | }, 150 | }, 151 | }); 152 | el.chartInstance = chartInstance; 153 | } else { 154 | chartInstance.data.labels = labels; 155 | chartInstance.data.datasets[0].data = averageData; 156 | chartInstance.data.datasets[1].data = medianData; 157 | chartInstance.options.scales.y = { 158 | title: { 159 | display: false, 160 | }, 161 | beginAtZero: true, 162 | ...yMaxConfig, 163 | }; 164 | chartInstance.update(); 165 | } 166 | } catch (error) { 167 | console.error("Error fetching or processing logs:", error); 168 | } 169 | } 170 | 171 | function calculateMedian(values) { 172 | values.sort((a, b) => a - b); 173 | const middle = Math.floor(values.length / 2); 174 | if (values.length % 2 === 0) { 175 | return (values[middle - 1] + values[middle]) / 2; 176 | } else { 177 | return values[middle]; 178 | } 179 | } 180 | 181 | function skipped(ctx, value) { 182 | return ctx.p0.skip || ctx.p1.skip ? value : undefined; 183 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | height: 100vh; 3 | width: 100vw; 4 | display: flex; 5 | flex-direction: column; 6 | } 7 | 8 | header { 9 | position: fixed; 10 | display: flex; 11 | align-items: center; 12 | flex-direction: row; 13 | width: 100vw; 14 | height: 4rem; 15 | transition: transform 0.2s ease; 16 | transform: translateY(0); 17 | } 18 | 19 | header.hidden { 20 | transform: translateY(-4rem); 21 | } 22 | 23 | .headerinner { 24 | display: flex; 25 | justify-content: space-between; 26 | align-items: center; 27 | margin: 0 auto; 28 | max-width: 65rem; 29 | width: 55rem; 30 | padding: 0 1rem 0 1rem; 31 | } 32 | 33 | .headerleft { 34 | display: flex; 35 | align-items: center; 36 | } 37 | 38 | .headerinner img { 39 | width: 2rem; 40 | height: auto; 41 | margin-right: 0.5rem; 42 | } 43 | 44 | .headerinner a { 45 | display: flex; 46 | align-items: center; 47 | color: #000; 48 | font-size: 1.17rem; 49 | } 50 | 51 | .headerinner svg { 52 | margin-left: 0.5rem; 53 | } 54 | 55 | body { 56 | --max-width: 55rem; 57 | --mobile-width: calc(100% - 2rem); 58 | background-color: #f6f8fa; 59 | font-family: -apple-system, BlinkMacSystemFont, Arial, sans-serif, "Open Sans", "Segoe UI", "Noto Sans", Helvetica, "Apple Color Emoji", "Segoe UI Emoji"; 60 | justify-content: center; 61 | line-height: 20px; 62 | min-width: 300px; 63 | margin: 0; 64 | padding: 0; 65 | text-align: center; 66 | } 67 | 68 | /* 文本样式 */ 69 | 70 | h1 { 71 | font-size: 2.3rem; 72 | margin: 1rem auto; 73 | color: #202B36; 74 | font-weight: 700; 75 | 76 | } 77 | 78 | h2 { 79 | font-size: 1rem; 80 | color: #202B36; 81 | } 82 | 83 | a { 84 | color: #3eaf7c; 85 | text-decoration: none; 86 | } 87 | 88 | footer { 89 | color: #9c9c9c; 90 | margin: 40px; 91 | text-align: center; 92 | padding-bottom: 1rem; 93 | } 94 | 95 | .pageContainer { 96 | background-color: #fff; 97 | border-radius: 5px; 98 | max-width: var(--max-width); 99 | margin: 0 auto; 100 | padding-bottom: 0.5rem; 101 | } 102 | 103 | .pageContainer, 104 | .clientInfo { 105 | box-shadow: 0 16px 48px #E3E7EB; 106 | } 107 | 108 | .headline { 109 | display: flex; 110 | flex-direction: column; 111 | align-items: center; 112 | justify-content: center; 113 | margin: 5rem auto 0rem; 114 | } 115 | 116 | .headline span { 117 | padding: 16px; 118 | font-size: 32px; 119 | font-weight: 700; 120 | line-height: normal; 121 | } 122 | 123 | .headline div { 124 | font-size: 14px; 125 | font-weight: 600; 126 | color: #7c7c7c; 127 | } 128 | 129 | .reportContainer { 130 | margin: 30px auto; 131 | width: 100%; 132 | } 133 | 134 | .statusContainer { 135 | border-radius: 5px; 136 | text-align: left; 137 | padding: 24px 24px 12px 24px; 138 | margin-top: 30px; 139 | } 140 | 141 | .statusStreamContainer { 142 | display: flex; 143 | align-items: center; 144 | justify-content: space-between; 145 | width: auto; 146 | max-width: var(--max-width); 147 | } 148 | 149 | .statusSquare { 150 | border-radius: 3px; 151 | height: 30px; 152 | width: 10px; 153 | min-width: 5px; 154 | margin-right: 3px; 155 | } 156 | 157 | .statusSquare:hover { 158 | transform: scaleY(1.2); 159 | overflow: hidden; 160 | } 161 | 162 | 163 | .statusHeader { 164 | display: flex; 165 | flex-direction: row; 166 | align-items: center; 167 | justify-content: space-between; 168 | margin-bottom: 0.7rem; 169 | } 170 | 171 | .statusHeader-left { 172 | display: flex; 173 | flex-direction: row; 174 | align-items: center; 175 | } 176 | 177 | .statusUptime { 178 | text-align: right; 179 | color: #7c7c7c; 180 | font-size: 12px; 181 | } 182 | 183 | .success { 184 | background-color: #00dfa2; 185 | color: #fff; 186 | } 187 | 188 | .failure { 189 | background-color: #ff0060; 190 | color: #fff; 191 | } 192 | 193 | .nodata { 194 | background-color: #dddddd; 195 | color: #7c7c7c; 196 | } 197 | 198 | .partial { 199 | background-color: #ffb84c; 200 | color: #fff; 201 | } 202 | 203 | .statusIcon { 204 | margin-right: 0.5rem; 205 | width: 1.28rem; 206 | } 207 | 208 | canvas { 209 | max-height: 128px; 210 | min-width: 100px; 211 | max-width: var(--max-width); 212 | padding: 0rem 1rem 0rem 1.4rem; 213 | margin-bottom: 3rem; 214 | } 215 | 216 | .span-wrapper { 217 | display: flex; 218 | justify-content: space-between; 219 | padding: 0 2rem 0 1.5rem; 220 | } 221 | 222 | .align-left { 223 | text-align: left; 224 | color: #7c7c7c; 225 | font-size: 12px; 226 | } 227 | 228 | .align-right { 229 | text-align: right; 230 | color: #7c7c7c; 231 | font-size: 12px; 232 | } 233 | 234 | .clientInfo { 235 | box-sizing: border-box; 236 | background-color: #fff; 237 | border-radius: 5px; 238 | padding: 10px; 239 | max-width: var(--max-width); 240 | margin: 2rem auto; 241 | } 242 | 243 | .clientInfo span { 244 | white-space: pre-wrap; 245 | font-size: 13px; 246 | color: #555555; 247 | } 248 | 249 | /* 定义滚动条的整体宽度 */ 250 | ::-webkit-scrollbar { 251 | width: 0.4rem; 252 | height: 0.5rem; 253 | } 254 | 255 | /* 定义滚动条滑块的颜色和样式 */ 256 | ::-webkit-scrollbar-thumb { 257 | background-color: #a2a2a2; 258 | border-radius: 0.5rem; 259 | } 260 | 261 | /* 鼠标悬停时改变滑块颜色 */ 262 | ::-webkit-scrollbar-thumb:hover { 263 | background-color: #7a7a7a; 264 | } 265 | 266 | /* 日志重载时的遮罩 */ 267 | .reports-container { 268 | position: relative; 269 | } 270 | 271 | .loading-mask { 272 | box-sizing: border-box; 273 | display: flex; 274 | align-items: center; 275 | height: 25rem; 276 | flex-direction: column; 277 | justify-content: space-evenly; 278 | } 279 | 280 | .hidden { 281 | display: none; 282 | } 283 | 284 | .loading-text { 285 | letter-spacing: 0.2em; 286 | } 287 | 288 | /* 加载动画 */ 289 | .loadinger { 290 | width: 60px; 291 | position: relative; 292 | } 293 | 294 | /* 每个矩形 */ 295 | .loadinger div { 296 | width: 10px; 297 | height: 30px; 298 | position: absolute; 299 | border-radius: 3px; 300 | } 301 | 302 | .loadinger div:nth-child(1) { 303 | left: 0; 304 | animation: l6 1s infinite linear; 305 | background: linear-gradient(#ff0060 0 0); 306 | } 307 | 308 | .loadinger div:nth-child(2) { 309 | left: 30%; 310 | animation: l6 1s infinite linear 0.33s; 311 | background: linear-gradient(#ffb84c 0 0); 312 | } 313 | 314 | .loadinger div:nth-child(3) { 315 | left: 60%; 316 | animation: l6 1s infinite linear 0.66s; 317 | background: linear-gradient(#00dfa2 0 0); 318 | } 319 | 320 | @keyframes l6 { 321 | 0% { 322 | top: 0; 323 | } 324 | 325 | 25% { 326 | top: 10px; 327 | } 328 | 329 | 50% { 330 | top: 0; 331 | } 332 | 333 | 75% { 334 | top: -10px; 335 | } 336 | 337 | 100% { 338 | top: 0; 339 | } 340 | } 341 | 342 | /* 图片跳动动画 */ 343 | .statusImg { 344 | width: 3.5rem; 345 | height: auto; 346 | transition: 347 | transform 0.3s ease-in-out, 348 | box-shadow 0.3s ease-in-out; 349 | } 350 | 351 | .statusImg:hover { 352 | transform: scale(1.1); 353 | } 354 | 355 | .icobeat { 356 | animation: bounce 1s ease-in-out infinite alternate; 357 | } 358 | 359 | @keyframes bounce { 360 | 361 | 0%, 362 | 100% { 363 | transform: translateY(0); 364 | } 365 | 366 | 50% { 367 | transform: translateY(-20px); 368 | } 369 | } 370 | 371 | 372 | /* 响应式设计 */ 373 | @media screen and (max-width: 640px) { 374 | .pageContainer { 375 | margin: 2rem auto; 376 | width: var(--mobile-width); 377 | max-width: var(--max-width); 378 | } 379 | 380 | .span-wrapper { 381 | padding: 0 1rem 0 1rem; 382 | } 383 | 384 | .statusUptime { 385 | margin-left: 0; 386 | margin-top: 8px; 387 | text-align: right; 388 | } 389 | 390 | .statusTitle { 391 | display: inline-block; 392 | } 393 | 394 | .statusStreamContainer { 395 | overflow-x: scroll; 396 | height: 37px; 397 | -ms-overflow-style: none; 398 | scrollbar-width: none; 399 | } 400 | 401 | /* 隐藏滚动条 */ 402 | .statusStreamContainer::-webkit-scrollbar { 403 | display: none; 404 | } 405 | 406 | .statusSquare { 407 | min-width: 7px; 408 | width: 100%; 409 | margin-right: 1px; 410 | } 411 | 412 | 413 | .statusContainer { 414 | padding: 10px; 415 | margin-top: 15px; 416 | } 417 | 418 | canvas { 419 | padding: 0rem 0.2rem 0rem 0.5rem; 420 | margin-bottom: 2rem; 421 | } 422 | 423 | .clientInfo { 424 | max-width: calc(100% - 2rem); 425 | } 426 | 427 | .loading-mask { 428 | height: 14rem; 429 | } 430 | 431 | /* 每个矩形 */ 432 | .loadinger div { 433 | width: 7px; 434 | } 435 | 436 | } 437 | 438 | @media screen and (min-width: 641px) and (max-width: 768px) { 439 | .pageContainer { 440 | margin: 2rem auto; 441 | width: var(--mobile-width); 442 | max-width: var(--max-width); 443 | } 444 | 445 | .span-wrapper { 446 | padding: 0 1rem 0 0.5rem; 447 | } 448 | 449 | .statusUptime { 450 | margin-left: 0; 451 | margin-top: 8px; 452 | text-align: right; 453 | } 454 | 455 | .statusTitle { 456 | display: inline-block; 457 | } 458 | 459 | .statusStreamContainer { 460 | overflow-x: scroll; 461 | height: 37px; 462 | -ms-overflow-style: none; 463 | scrollbar-width: none; 464 | } 465 | 466 | /* 隐藏滚动条 */ 467 | .statusStreamContainer::-webkit-scrollbar { 468 | display: none; 469 | } 470 | 471 | .statusSquare { 472 | min-width: 7px; 473 | width: 100%; 474 | margin-right: 2px; 475 | } 476 | 477 | .statusContainer { 478 | padding: 10px; 479 | margin-top: 15px; 480 | } 481 | 482 | canvas { 483 | padding: 0rem 0.2rem 0rem 0.5rem; 484 | margin-bottom: 2rem; 485 | } 486 | 487 | .clientInfo { 488 | max-width: calc(100% - 2rem); 489 | } 490 | 491 | .loading-mask { 492 | height: 14rem; 493 | } 494 | 495 | /* 每个矩形 */ 496 | .loadinger div { 497 | width: 8px; 498 | } 499 | 500 | } 501 | 502 | @media screen and (min-width: 769px) and (max-width: 1024px) { 503 | .pageContainer { 504 | margin: 2rem auto; 505 | width: var(--mobile-width); 506 | max-width: var(--max-width); 507 | } 508 | 509 | .reportContainer { 510 | max-width: var(--max-width); 511 | } 512 | 513 | .statusUptime { 514 | margin-left: 0; 515 | margin-top: 3px; 516 | text-align: right; 517 | } 518 | 519 | .statusTitle { 520 | display: inline-block; 521 | } 522 | 523 | .statusStreamContainer { 524 | overflow-x: scroll; 525 | height: 37px; 526 | -ms-overflow-style: none; 527 | scrollbar-width: none; 528 | } 529 | 530 | /* 隐藏滚动条 */ 531 | .statusStreamContainer::-webkit-scrollbar { 532 | display: none; 533 | } 534 | 535 | .statusSquare { 536 | min-width: 7px; 537 | margin-right: 1px; 538 | } 539 | 540 | .statusContainer { 541 | margin-top: 15px; 542 | } 543 | 544 | canvas { 545 | margin-bottom: 2rem; 546 | } 547 | 548 | .clientInfo { 549 | max-width: calc(100% - 2rem); 550 | width: var(--max-width); 551 | } 552 | 553 | .loading-mask { 554 | height: 20rem; 555 | } 556 | } 557 | 558 | 559 | @media screen and (min-width: 1280px) { 560 | .pageContainer { 561 | margin: 2rem auto; 562 | width: var(--mobile-width); 563 | max-width: var(--max-width); 564 | } 565 | 566 | .reportContainer { 567 | max-width: var(--max-width); 568 | } 569 | 570 | .statusUptime { 571 | margin-left: 0; 572 | margin-top: 3px; 573 | text-align: right; 574 | } 575 | 576 | .statusTitle { 577 | display: inline-block; 578 | } 579 | 580 | .statusStreamContainer { 581 | overflow-x: scroll; 582 | height: 37px; 583 | -ms-overflow-style: none; 584 | scrollbar-width: none; 585 | } 586 | 587 | /* 隐藏滚动条 */ 588 | .statusStreamContainer::-webkit-scrollbar { 589 | display: none; 590 | } 591 | 592 | .statusSquare { 593 | min-width: 7px; 594 | margin-right: 1px; 595 | } 596 | 597 | .statusContainer { 598 | margin-top: 15px; 599 | } 600 | 601 | canvas { 602 | margin-bottom: 2rem; 603 | } 604 | 605 | .loading-mask { 606 | height: 20rem; 607 | } 608 | } -------------------------------------------------------------------------------- /src/js/scrollreveal.min.js: -------------------------------------------------------------------------------- 1 | /*! @license ScrollReveal v4.0.0 2 | 3 | Copyright 2018 Fisssion LLC. 4 | 5 | Licensed under the GNU General Public License 3.0 for 6 | compatible open source projects and non-commercial use. 7 | 8 | For commercial sites, themes, projects, and applications, 9 | keep your source code private/proprietary by purchasing 10 | a commercial license from https://scrollrevealjs.org/。 11 | */ 12 | var ScrollReveal=function(){"use strict";var r={delay:0,distance:"0",duration:600,easing:"cubic-bezier(0.5, 0, 0, 1)",interval:0,opacity:0,origin:"bottom",rotate:{x:0,y:0,z:0},scale:1,cleanup:!0,container:document.documentElement,desktop:!0,mobile:!0,reset:!1,useDelay:"always",viewFactor:0,viewOffset:{top:0,right:0,bottom:0,left:0},afterReset:function(){},afterReveal:function(){},beforeReset:function(){},beforeReveal:function(){}},n={clean:function(){},destroy:function(){},reveal:function(){},sync:function(){},get noop(){return!0}};function o(e){return"object"==typeof window.Node?e instanceof window.Node:null!==e&&"object"==typeof e&&"number"==typeof e.nodeType&&"string"==typeof e.nodeName}function u(e,t){if(void 0===t&&(t=document),e instanceof Array)return e.filter(o);if(o(e))return[e];if(n=e,i=Object.prototype.toString.call(n),"object"==typeof window.NodeList?n instanceof window.NodeList:null!==n&&"object"==typeof n&&"number"==typeof n.length&&/^\[object (HTMLCollection|NodeList|Object)\]$/.test(i)&&(0===n.length||o(n[0])))return Array.prototype.slice.call(e);var n,i;if("string"==typeof e)try{var r=t.querySelectorAll(e);return Array.prototype.slice.call(r)}catch(e){return[]}return[]}function s(e){return null!==e&&e instanceof Object&&(e.constructor===Object||"[object Object]"===Object.prototype.toString.call(e))}function f(n,i){if(s(n))return Object.keys(n).forEach(function(e){return i(n[e],e,n)});if(n instanceof Array)return n.forEach(function(e,t){return i(e,t,n)});throw new TypeError("Expected either an array or object literal.")}function h(e){for(var t=[],n=arguments.length-1;0=[].concat(r.body).shift())return g.call(this,n,i,-1,t),c.call(this,e,{reveal:!0,pristine:t});if(!n.blocked.foot&&i===[].concat(o.foot).shift()&&i<=[].concat(r.body).pop())return g.call(this,n,i,1,t),c.call(this,e,{reveal:!0,pristine:t})}}function v(e){var t=Math.abs(e);if(isNaN(t))throw new RangeError("Invalid sequence interval.");this.id=y(),this.interval=Math.max(t,16),this.members=[],this.models={},this.blocked={head:!1,foot:!1}}function d(e,i,r){var o=this;this.head=[],this.body=[],this.foot=[],f(e.members,function(e,t){var n=r.elements[e];n&&n[i]&&o.body.push(t)}),this.body.length&&f(e.members,function(e,t){var n=r.elements[e];n&&!n[i]&&(t
last updated on: Loading...
UA : $uag