mirror of
https://github.com/whyour/qinglong.git
synced 2026-08-07 09:14:32 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23f21d7448 | |||
| 34bc18cb25 | |||
| 0995808309 | |||
| 07bf0c705b | |||
| fd516977e3 | |||
| c39f4ef846 |
@@ -7,8 +7,6 @@ on:
|
||||
branches:
|
||||
- "master"
|
||||
- "develop"
|
||||
- "debian"
|
||||
- "debian-dev"
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
@@ -165,9 +163,9 @@ jobs:
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'debian-dev') }}
|
||||
type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'debian') }}
|
||||
type=raw,value=${{ steps.version.outputs.version }}-debian,enable=${{ github.ref == format('refs/heads/{0}', 'debian') }}
|
||||
type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
|
||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||
type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
@@ -185,21 +183,22 @@ jobs:
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
# linux/s390x npm 暂不可用
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-debian
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: |
|
||||
echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
build310:
|
||||
if: ${{ github.ref_name == 'debian' }}
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
needs: build-static
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -257,42 +256,17 @@ jobs:
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
# linux/s390x npm 暂不可用
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386
|
||||
context: .
|
||||
file: ./docker/310.Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
whyour/qinglong:debian-python3.10
|
||||
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
|
||||
whyour/qinglong:python3.10
|
||||
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: |
|
||||
echo ${{ steps.docker_build_310.outputs.digest }}
|
||||
|
||||
publish:
|
||||
if: ${{ github.ref_name == 'debian' }}
|
||||
needs: build
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: "8.3.1"
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
cache: "pnpm"
|
||||
|
||||
- name: build front and back
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:front
|
||||
pnpm build:back
|
||||
|
||||
- name: publich npm package
|
||||
run: |
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
|
||||
npm publish
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
/.tmp/
|
||||
/.github/
|
||||
/.vscode/
|
||||
/.history/
|
||||
/back/**/*.ts
|
||||
/back/**/*.json
|
||||
/cli/
|
||||
/data/
|
||||
/src/
|
||||
/static/**/*.js.map
|
||||
/static/**/*.gz
|
||||
/.editorconfig
|
||||
/.gitignore
|
||||
/.prettierignore
|
||||
/.prettierrc
|
||||
/.umirc.ts
|
||||
/nodemon.json
|
||||
/pnpm-lock.yaml
|
||||
/tsconfig.back.json
|
||||
/tsconfig.json
|
||||
/typings.d.ts
|
||||
/.env
|
||||
+1
-1
@@ -14,7 +14,7 @@ export default (app: Router) => {
|
||||
app.use('/configs', route);
|
||||
|
||||
route.get(
|
||||
'/samples',
|
||||
'/sample',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.send({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import CronService from '../services/cron';
|
||||
import CronViewService from '../services/cronView';
|
||||
import CronStatsService from '../services/cronStats';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { commonCronSchema } from '../validation/schedule';
|
||||
|
||||
@@ -141,6 +142,58 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/stats',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const cronStatsService = Container.get(CronStatsService);
|
||||
const data = await cronStatsService.stats();
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/stats/trend',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const cronStatsService = Container.get(CronStatsService);
|
||||
const data = await cronStatsService.trend();
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/stats/top-duration',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const cronStatsService = Container.get(CronStatsService);
|
||||
const data = await cronStatsService.topDuration();
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/stats/top-count',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const cronStatsService = Container.get(CronStatsService);
|
||||
const data = await cronStatsService.topCount();
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
|
||||
@@ -49,38 +49,3 @@ export const NotificationModeStringMap = {
|
||||
19: 'ntfy',
|
||||
20: 'wxPusherBot',
|
||||
} as const;
|
||||
|
||||
export const LINUX_DEPENDENCE_COMMAND: Record<
|
||||
'Debian' | 'Ubuntu' | 'Alpine',
|
||||
{
|
||||
install: string;
|
||||
uninstall: string;
|
||||
info: string;
|
||||
check(info: string): boolean;
|
||||
}
|
||||
> = {
|
||||
Debian: {
|
||||
install: 'apt-get install -y',
|
||||
uninstall: 'apt-get remove -y',
|
||||
info: 'dpkg-query -s',
|
||||
check(info: string) {
|
||||
return info.includes('install ok installed');
|
||||
},
|
||||
},
|
||||
Ubuntu: {
|
||||
install: 'apt-get install -y',
|
||||
uninstall: 'apt-get remove -y',
|
||||
info: 'dpkg-query -s',
|
||||
check(info: string) {
|
||||
return info.includes('install ok installed');
|
||||
},
|
||||
},
|
||||
Alpine: {
|
||||
install: 'apk add --no-check-certificate',
|
||||
uninstall: 'apk del',
|
||||
info: 'apk info -es',
|
||||
check(info: string) {
|
||||
return info.includes('installed');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+3
-148
@@ -10,12 +10,9 @@ import Logger from '../loaders/logger';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import { DependenceTypes } from '../data/dependence';
|
||||
import { FormData } from 'undici';
|
||||
import os from 'os';
|
||||
|
||||
export * from './share';
|
||||
|
||||
let osType: 'Debian' | 'Ubuntu' | 'Alpine' | undefined;
|
||||
|
||||
export async function getFileContentByName(fileName: string) {
|
||||
const _exsit = await fileExist(fileName);
|
||||
if (_exsit) {
|
||||
@@ -553,7 +550,7 @@ except:
|
||||
spec=u.find_spec(name)
|
||||
print(name if spec else '')
|
||||
''')"`,
|
||||
[DependenceTypes.linux]: `apt-get info ${name}`,
|
||||
[DependenceTypes.linux]: `apk info -es ${name}`,
|
||||
};
|
||||
|
||||
return baseCommands[type];
|
||||
@@ -564,7 +561,7 @@ export function getInstallCommand(type: DependenceTypes, name: string): string {
|
||||
[DependenceTypes.nodejs]: 'pnpm add -g',
|
||||
[DependenceTypes.python3]:
|
||||
'pip3 install --disable-pip-version-check --root-user-action=ignore',
|
||||
[DependenceTypes.linux]: 'apt install -y',
|
||||
[DependenceTypes.linux]: 'apk add --no-check-certificate',
|
||||
};
|
||||
|
||||
let command = baseCommands[type];
|
||||
@@ -584,7 +581,7 @@ export function getUninstallCommand(
|
||||
[DependenceTypes.nodejs]: 'pnpm remove -g',
|
||||
[DependenceTypes.python3]:
|
||||
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
|
||||
[DependenceTypes.linux]: 'apt remove -y',
|
||||
[DependenceTypes.linux]: 'apk del',
|
||||
};
|
||||
|
||||
return `${baseCommands[type]} ${name.trim()}`;
|
||||
@@ -593,145 +590,3 @@ export function getUninstallCommand(
|
||||
export function isDemoEnv() {
|
||||
return process.env.DeployEnv === 'demo';
|
||||
}
|
||||
|
||||
async function getOSReleaseInfo(): Promise<string> {
|
||||
const osRelease = await fs.readFile('/etc/os-release', 'utf8');
|
||||
return osRelease;
|
||||
}
|
||||
|
||||
function isDebian(osReleaseInfo: string): boolean {
|
||||
return osReleaseInfo.includes('Debian');
|
||||
}
|
||||
|
||||
function isUbuntu(osReleaseInfo: string): boolean {
|
||||
return osReleaseInfo.includes('Ubuntu');
|
||||
}
|
||||
|
||||
function isCentOS(osReleaseInfo: string): boolean {
|
||||
return osReleaseInfo.includes('CentOS') || osReleaseInfo.includes('Red Hat');
|
||||
}
|
||||
|
||||
function isAlpine(osReleaseInfo: string): boolean {
|
||||
return osReleaseInfo.includes('Alpine');
|
||||
}
|
||||
|
||||
export async function detectOS(): Promise<
|
||||
'Debian' | 'Ubuntu' | 'Alpine' | undefined
|
||||
> {
|
||||
if (osType) return osType;
|
||||
const platform = os.platform();
|
||||
|
||||
if (platform === 'linux') {
|
||||
const osReleaseInfo = await getOSReleaseInfo();
|
||||
if (isDebian(osReleaseInfo)) {
|
||||
osType = 'Debian';
|
||||
} else if (isUbuntu(osReleaseInfo)) {
|
||||
osType = 'Ubuntu';
|
||||
} else if (isAlpine(osReleaseInfo)) {
|
||||
osType = 'Alpine';
|
||||
} else {
|
||||
Logger.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
|
||||
console.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
|
||||
}
|
||||
} else if (platform === 'darwin') {
|
||||
osType = undefined;
|
||||
} else {
|
||||
Logger.error(`Unsupported platform: ${platform}`);
|
||||
console.error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
return osType;
|
||||
}
|
||||
|
||||
async function getCurrentMirrorDomain(
|
||||
filePath: string,
|
||||
): Promise<string | null> {
|
||||
const fileContent = await fs.readFile(filePath, 'utf8');
|
||||
const lines = fileContent.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/https?:\/\/[^\/]+/);
|
||||
if (match) {
|
||||
return match[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function replaceDomainInFile(
|
||||
filePath: string,
|
||||
oldDomainWithScheme: string,
|
||||
newDomainWithScheme: string,
|
||||
): Promise<void> {
|
||||
let fileContent = await fs.readFile(filePath, 'utf8');
|
||||
let updatedContent = fileContent.replace(
|
||||
new RegExp(oldDomainWithScheme, 'g'),
|
||||
newDomainWithScheme,
|
||||
);
|
||||
|
||||
if (!newDomainWithScheme.endsWith('/')) {
|
||||
newDomainWithScheme += '/';
|
||||
}
|
||||
|
||||
await writeFileWithLock(filePath, updatedContent);
|
||||
}
|
||||
|
||||
async function _updateLinuxMirror(
|
||||
osType: string,
|
||||
mirrorDomainWithScheme: string,
|
||||
): Promise<string> {
|
||||
let filePath: string, currentDomainWithScheme: string | null;
|
||||
switch (osType) {
|
||||
case 'Debian':
|
||||
filePath = '/etc/apt/sources.list.d/debian.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
await replaceDomainInFile(
|
||||
filePath,
|
||||
currentDomainWithScheme,
|
||||
mirrorDomainWithScheme || 'http://deb.debian.org',
|
||||
);
|
||||
return 'apt-get update';
|
||||
} else {
|
||||
throw Error(`Current mirror domain not found.`);
|
||||
}
|
||||
case 'Ubuntu':
|
||||
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
await replaceDomainInFile(
|
||||
filePath,
|
||||
currentDomainWithScheme,
|
||||
mirrorDomainWithScheme || 'http://archive.ubuntu.com',
|
||||
);
|
||||
return 'apt-get update';
|
||||
} else {
|
||||
throw Error(`Current mirror domain not found.`);
|
||||
}
|
||||
case 'Alpine':
|
||||
filePath = '/etc/apk/repositories';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
await replaceDomainInFile(
|
||||
filePath,
|
||||
currentDomainWithScheme,
|
||||
mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org',
|
||||
);
|
||||
return 'apk update';
|
||||
} else {
|
||||
throw Error(`Current mirror domain not found.`);
|
||||
}
|
||||
default:
|
||||
throw Error('Unsupported OS type for updating mirrors.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
|
||||
const detectedOS = await detectOS();
|
||||
if (!detectedOS) {
|
||||
throw Error(`Unknown Linux Distribution`);
|
||||
}
|
||||
return await _updateLinuxMirror(detectedOS, mirror);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { sequelize } from '.';
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
|
||||
export class CronLog {
|
||||
id?: number;
|
||||
cron_id: number;
|
||||
cron_name: string;
|
||||
start_time: number;
|
||||
duration: number;
|
||||
|
||||
constructor(options: CronLog) {
|
||||
this.cron_id = options.cron_id;
|
||||
this.cron_name = options.cron_name;
|
||||
this.start_time = options.start_time;
|
||||
this.duration = options.duration;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CronLogInstance extends Model<CronLog, CronLog>, CronLog {}
|
||||
export const CronLogModel = sequelize.define<CronLogInstance>(
|
||||
'CronLog',
|
||||
{
|
||||
cron_id: DataTypes.NUMBER,
|
||||
cron_name: DataTypes.STRING,
|
||||
start_time: DataTypes.NUMBER,
|
||||
duration: DataTypes.NUMBER,
|
||||
},
|
||||
{
|
||||
indexes: [{ fields: ['cron_id'] }, { fields: ['start_time'] }],
|
||||
},
|
||||
);
|
||||
@@ -6,6 +6,7 @@ import { AppModel } from '../data/open';
|
||||
import { SystemModel } from '../data/system';
|
||||
import { SubscriptionModel } from '../data/subscription';
|
||||
import { CrontabViewModel } from '../data/cronView';
|
||||
import { CronLogModel } from '../data/cronLog';
|
||||
import { sequelize } from '../data';
|
||||
|
||||
export default async () => {
|
||||
@@ -17,6 +18,7 @@ export default async () => {
|
||||
await EnvModel.sync();
|
||||
await SubscriptionModel.sync();
|
||||
await CrontabViewModel.sync();
|
||||
await CronLogModel.sync();
|
||||
|
||||
// 初始化新增字段
|
||||
const migrations = [
|
||||
|
||||
+41
-13
@@ -2,6 +2,7 @@ import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import config from '../config';
|
||||
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
|
||||
import { CronLog, CronLogModel } from '../data/cronLog';
|
||||
import { exec, execSync } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
@@ -80,7 +81,7 @@ export default class CronService {
|
||||
return doc;
|
||||
}
|
||||
|
||||
if (!this.isSpecialSchedule(doc.schedule)) {
|
||||
if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
@@ -111,9 +112,11 @@ export default class CronService {
|
||||
return newDoc;
|
||||
}
|
||||
|
||||
await cronClient.delCron([String(newDoc.id)]);
|
||||
if (this.isNodeCron(doc)) {
|
||||
await cronClient.delCron([String(doc.id)]);
|
||||
}
|
||||
|
||||
if (!this.isSpecialSchedule(newDoc.schedule)) {
|
||||
if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
@@ -174,6 +177,18 @@ export default class CronService {
|
||||
{ ...pickBy(options, (v) => v === 0 || !!v) },
|
||||
{ where: { id } },
|
||||
);
|
||||
|
||||
if (status === CrontabStatus.idle && last_running_time > 0) {
|
||||
const cronName = (cron.name || cron.command || '').substring(0, 255);
|
||||
await CronLogModel.create(
|
||||
new CronLog({
|
||||
cron_id: id,
|
||||
cron_name: cronName,
|
||||
start_time: last_execution_time,
|
||||
duration: last_running_time,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,19 +590,20 @@ export default class CronService {
|
||||
public async enabled(ids: number[]) {
|
||||
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
|
||||
const docs = await CrontabModel.findAll({ where: { id: ids } });
|
||||
const crons = docs.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
}));
|
||||
const sixCron = docs
|
||||
.filter((x) => this.isNodeCron(x) && !this.isSpecialSchedule(x.schedule))
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
}));
|
||||
|
||||
if (isDemoEnv()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await cronClient.addCron(crons);
|
||||
await cronClient.addCron(sixCron);
|
||||
await this.setCrontab();
|
||||
}
|
||||
|
||||
@@ -687,6 +703,13 @@ export default class CronService {
|
||||
|
||||
await writeFileWithLock(config.crontabFile, crontab_string);
|
||||
|
||||
try {
|
||||
execSync(`crontab ${config.crontabFile}`);
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.message || String(error);
|
||||
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
|
||||
}
|
||||
|
||||
await CrontabModel.update({ saved: true }, { where: {} });
|
||||
}
|
||||
|
||||
@@ -732,7 +755,12 @@ export default class CronService {
|
||||
public async autosave_crontab() {
|
||||
const tabs = await this.crontabs();
|
||||
const regularCrons = tabs.data
|
||||
.filter((x) => x.isDisabled !== 1 && !this.isSpecialSchedule(x.schedule))
|
||||
.filter(
|
||||
(x) =>
|
||||
x.isDisabled !== 1 &&
|
||||
this.isNodeCron(x) &&
|
||||
!this.isSpecialSchedule(x.schedule),
|
||||
)
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { CronLog, CronLogModel } from '../data/cronLog';
|
||||
import { Op } from 'sequelize';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
type GroupedLog = {
|
||||
cron_id: number;
|
||||
cron_name: string;
|
||||
durations: number[];
|
||||
};
|
||||
|
||||
@Service()
|
||||
export default class CronStatsService {
|
||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||
|
||||
private groupLogsByCronId(logs: CronLog[]): Record<number, GroupedLog> {
|
||||
const grouped: Record<number, GroupedLog> = {};
|
||||
for (const log of logs) {
|
||||
if (!grouped[log.cron_id]) {
|
||||
grouped[log.cron_id] = {
|
||||
cron_id: log.cron_id,
|
||||
cron_name: log.cron_name,
|
||||
durations: [],
|
||||
};
|
||||
}
|
||||
grouped[log.cron_id].durations.push(log.duration);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private avgOf(nums: number[]): number {
|
||||
if (nums.length === 0) return 0;
|
||||
return Math.round(nums.reduce((a, b) => a + b, 0) / nums.length);
|
||||
}
|
||||
|
||||
private getTodayRange() {
|
||||
return {
|
||||
start: dayjs().startOf('day').unix(),
|
||||
end: dayjs().endOf('day').unix(),
|
||||
};
|
||||
}
|
||||
|
||||
public async stats() {
|
||||
const { start, end } = this.getTodayRange();
|
||||
|
||||
const [allCrons, todayLogs] = await Promise.all([
|
||||
CrontabModel.findAll({ where: {} }),
|
||||
CronLogModel.findAll({
|
||||
where: { start_time: { [Op.between]: [start, end] } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const total = allCrons.length;
|
||||
const enabled = allCrons.filter((c: any) => c.isDisabled !== 1).length;
|
||||
const disabled = allCrons.filter((c: any) => c.isDisabled === 1).length;
|
||||
|
||||
const todayCount = todayLogs.length;
|
||||
const todayTotalDuration = todayLogs.reduce(
|
||||
(sum: number, l: any) => sum + (l.duration || 0),
|
||||
0,
|
||||
);
|
||||
const todayAvgDuration =
|
||||
todayCount > 0 ? Math.round(todayTotalDuration / todayCount) : 0;
|
||||
|
||||
return {
|
||||
total,
|
||||
enabled,
|
||||
disabled,
|
||||
today: {
|
||||
count: todayCount,
|
||||
avgDuration: todayAvgDuration,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async trend() {
|
||||
const days = 7;
|
||||
const result: Array<{ date: string; count: number }> = [];
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const dayStart = dayjs().subtract(i, 'day').startOf('day').unix();
|
||||
const dayEnd = dayjs().subtract(i, 'day').endOf('day').unix();
|
||||
const date = dayjs().subtract(i, 'day').format('MM-DD');
|
||||
|
||||
const logs = await CronLogModel.findAll({
|
||||
where: { start_time: { [Op.between]: [dayStart, dayEnd] } },
|
||||
});
|
||||
|
||||
result.push({ date, count: logs.length });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async topDuration(limit = 5) {
|
||||
const { start, end } = this.getTodayRange();
|
||||
|
||||
const logs = await CronLogModel.findAll({
|
||||
where: { start_time: { [Op.between]: [start, end] } },
|
||||
});
|
||||
|
||||
const grouped = this.groupLogsByCronId(logs as any);
|
||||
|
||||
return Object.values(grouped)
|
||||
.map((g) => ({
|
||||
cron_id: g.cron_id,
|
||||
cron_name: g.cron_name,
|
||||
count: g.durations.length,
|
||||
avgDuration: this.avgOf(g.durations),
|
||||
maxDuration: Math.max(...g.durations),
|
||||
}))
|
||||
.sort((a, b) => b.avgDuration - a.avgDuration)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
public async topCount(limit = 5) {
|
||||
const { start, end } = this.getTodayRange();
|
||||
|
||||
const logs = await CronLogModel.findAll({
|
||||
where: { start_time: { [Op.between]: [start, end] } },
|
||||
});
|
||||
|
||||
const grouped = this.groupLogsByCronId(logs as any);
|
||||
|
||||
return Object.values(grouped)
|
||||
.map((g) => ({
|
||||
cron_id: g.cron_id,
|
||||
cron_name: g.cron_name,
|
||||
count: g.durations.length,
|
||||
avgDuration: this.avgOf(g.durations),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, limit);
|
||||
}
|
||||
}
|
||||
+17
-61
@@ -22,8 +22,6 @@ import {
|
||||
} from '../config/util';
|
||||
import dayjs from 'dayjs';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import { detectOS } from '../config/util';
|
||||
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
|
||||
|
||||
@Service()
|
||||
export default class DependenceService {
|
||||
@@ -161,19 +159,8 @@ export default class DependenceService {
|
||||
const docs = await DependenceModel.findAll({ where: { id: ids } });
|
||||
for (const doc of docs) {
|
||||
taskLimit.removeQueuedDependency(doc);
|
||||
let depInstallCommand = getInstallCommand(doc.type, doc.name);
|
||||
let depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
|
||||
const isLinuxDependence = doc.type === DependenceTypes.linux;
|
||||
|
||||
if (isLinuxDependence) {
|
||||
const osType = await detectOS();
|
||||
if (!osType) {
|
||||
continue;
|
||||
}
|
||||
const linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
|
||||
depInstallCommand = `${linuxCommand.install} ${doc.name.trim()}`;
|
||||
depUnInstallCommand = `${linuxCommand.uninstall} ${doc.name.trim()}`;
|
||||
}
|
||||
const depInstallCommand = getInstallCommand(doc.type, doc.name);
|
||||
const depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
|
||||
const pids = await Promise.all([
|
||||
getPid(depInstallCommand),
|
||||
getPid(depUnInstallCommand),
|
||||
@@ -230,54 +217,23 @@ export default class DependenceService {
|
||||
if (taskLimit.firstDependencyId !== dependency.id) {
|
||||
return resolve(null);
|
||||
}
|
||||
const depIds = [dependency.id!];
|
||||
let depName = dependency.name.trim();
|
||||
const actionText = isInstall ? '安装' : '删除';
|
||||
const socketMessageType = isInstall
|
||||
? 'installDependence'
|
||||
: 'uninstallDependence';
|
||||
const isNodeDependence = dependency.type === DependenceTypes.nodejs;
|
||||
const isLinuxDependence = dependency.type === DependenceTypes.linux;
|
||||
const isPythonDependence = dependency.type === DependenceTypes.python3;
|
||||
const osType = await detectOS();
|
||||
let linuxCommand = {} as typeof LINUX_DEPENDENCE_COMMAND.Alpine;
|
||||
taskLimit.removeQueuedDependency(dependency);
|
||||
if (isLinuxDependence) {
|
||||
if (!osType) {
|
||||
await DependenceModel.update(
|
||||
{ status: DependenceStatus.installFailed },
|
||||
{ where: { id: depIds } },
|
||||
);
|
||||
const startTime = dayjs();
|
||||
const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)}\n\n当前系统不支持\n\n依赖${actionText}失败,结束时间 ${startTime.format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)},耗时 ${startTime.diff(startTime, 'second')} 秒`;
|
||||
this.sockService.sendMessage({
|
||||
type: socketMessageType,
|
||||
message,
|
||||
references: depIds,
|
||||
});
|
||||
this.updateLog(depIds, message);
|
||||
return resolve(null);
|
||||
}
|
||||
linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
|
||||
}
|
||||
|
||||
taskLimit.removeQueuedDependency(dependency);
|
||||
|
||||
const depIds = [dependency.id!];
|
||||
const status = isInstall
|
||||
? DependenceStatus.installing
|
||||
: DependenceStatus.removing;
|
||||
await DependenceModel.update({ status }, { where: { id: depIds } });
|
||||
|
||||
let command = isInstall
|
||||
const socketMessageType = isInstall
|
||||
? 'installDependence'
|
||||
: 'uninstallDependence';
|
||||
let depName = dependency.name.trim();
|
||||
const command = isInstall
|
||||
? getInstallCommand(dependency.type, depName)
|
||||
: getUninstallCommand(dependency.type, depName);
|
||||
if (isLinuxDependence) {
|
||||
command = isInstall
|
||||
? `${linuxCommand.install} ${depName.trim()}`
|
||||
: `${linuxCommand.uninstall} ${depName.trim()}`;
|
||||
}
|
||||
const actionText = isInstall ? '安装' : '删除';
|
||||
const startTime = dayjs();
|
||||
|
||||
const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format(
|
||||
@@ -292,12 +248,8 @@ export default class DependenceService {
|
||||
|
||||
// 判断是否已经安装过依赖
|
||||
if (isInstall && !force) {
|
||||
let getCommand = getGetCommand(dependency.type, depName);
|
||||
const getCommand = getGetCommand(dependency.type, depName);
|
||||
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
||||
if (isLinuxDependence) {
|
||||
getCommand = `${linuxCommand.info} ${depName}`;
|
||||
}
|
||||
|
||||
let depVersion = '';
|
||||
if (depName.includes(depVersionStr)) {
|
||||
const symbolRegx = new RegExp(
|
||||
@@ -309,6 +261,10 @@ export default class DependenceService {
|
||||
depVersion = _depVersion;
|
||||
}
|
||||
}
|
||||
const isNodeDependence = dependency.type === DependenceTypes.nodejs;
|
||||
const isLinuxDependence = dependency.type === DependenceTypes.linux;
|
||||
const isPythonDependence =
|
||||
dependency.type === DependenceTypes.python3;
|
||||
const depInfo = (await promiseExecSuccess(getCommand))
|
||||
.replace(/\s{2,}/, ' ')
|
||||
.replace(/\s+$/, '');
|
||||
@@ -317,7 +273,7 @@ export default class DependenceService {
|
||||
depInfo &&
|
||||
((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
|
||||
(isLinuxDependence &&
|
||||
linuxCommand.check(depInfo.toLocaleLowerCase())) ||
|
||||
depInfo.toLocaleLowerCase().includes('installed')) ||
|
||||
isPythonDependence) &&
|
||||
(!depVersion || depInfo.includes(depVersion))
|
||||
) {
|
||||
|
||||
+24
-10
@@ -37,7 +37,6 @@ import ScheduleService, { TaskCallbacks } from './schedule';
|
||||
import SockService from './sock';
|
||||
import os from 'os';
|
||||
import dayjs from 'dayjs';
|
||||
import { updateLinuxMirrorFile } from '../config/util';
|
||||
|
||||
@Service()
|
||||
export default class SystemService {
|
||||
@@ -215,11 +214,33 @@ export default class SystemService {
|
||||
onEnd?: () => void,
|
||||
) {
|
||||
const oDoc = await this.getSystemConfig();
|
||||
await this.updateAuthDb({
|
||||
...oDoc,
|
||||
info: { ...oDoc.info, ...info },
|
||||
});
|
||||
let defaultDomain = 'https://dl-cdn.alpinelinux.org';
|
||||
let targetDomain = 'https://dl-cdn.alpinelinux.org';
|
||||
if (os.platform() !== 'linux') {
|
||||
return;
|
||||
}
|
||||
const command = await updateLinuxMirrorFile(info.linuxMirror || '');
|
||||
let hasError = false;
|
||||
const content = await fs.promises.readFile('/etc/apk/repositories', {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const domainMatch = content.match(/(http.*)\/alpine\/.*/);
|
||||
if (domainMatch) {
|
||||
defaultDomain = domainMatch[1];
|
||||
}
|
||||
if (info.linuxMirror) {
|
||||
targetDomain = info.linuxMirror;
|
||||
}
|
||||
const command = `sed -i 's/${defaultDomain.replace(
|
||||
/\//g,
|
||||
'\\/',
|
||||
)}/${targetDomain.replace(
|
||||
/\//g,
|
||||
'\\/',
|
||||
)}/g' /etc/apk/repositories && apk update -f`;
|
||||
|
||||
this.scheduleService.runTask(
|
||||
command,
|
||||
{
|
||||
@@ -233,15 +254,8 @@ export default class SystemService {
|
||||
message: 'update linux mirror end',
|
||||
});
|
||||
onEnd?.();
|
||||
if (!hasError) {
|
||||
await this.updateAuthDb({
|
||||
...oDoc,
|
||||
info: { ...oDoc.info, ...info },
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
hasError = true;
|
||||
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
|
||||
},
|
||||
onLog: async (message: string) => {
|
||||
|
||||
+42
-65
@@ -1,18 +1,13 @@
|
||||
FROM node:22-slim AS nodebuilder
|
||||
|
||||
FROM python:3.10-slim-bookworm AS builder
|
||||
FROM python:3.10-alpine3.18 AS builder
|
||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
|
||||
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
RUN set -x && \
|
||||
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
apt-get update && \
|
||||
apt-get install --no-install-recommends -y libatomic1 && \
|
||||
npm i -g pnpm@8.3.1 && \
|
||||
cd /tmp/build && \
|
||||
pnpm install --prod
|
||||
RUN set -x \
|
||||
&& apk update \
|
||||
&& apk add nodejs npm git \
|
||||
&& npm i -g pnpm@8.3.1 pm2 ts-node \
|
||||
&& cd /tmp/build \
|
||||
&& pnpm install --prod
|
||||
|
||||
FROM python:3.10-slim-bookworm
|
||||
FROM python:3.10-alpine
|
||||
|
||||
ARG QL_MAINTAINER="whyour"
|
||||
LABEL maintainer="${QL_MAINTAINER}"
|
||||
@@ -26,63 +21,51 @@ ENV QL_DIR=/ql \
|
||||
SHELL=/bin/bash \
|
||||
PS1="\u@\h:\w \$ "
|
||||
|
||||
ARG QL_UID=5432
|
||||
ARG QL_GID=5432
|
||||
RUN groupadd -g ${QL_GID} qinglong && \
|
||||
useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \
|
||||
mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \
|
||||
chmod 700 /home/qinglong/.ssh && \
|
||||
chown -R ${QL_UID}:${QL_GID} /home/qinglong
|
||||
VOLUME /ql/data
|
||||
|
||||
EXPOSE 5700
|
||||
|
||||
ENV QL_USER=qinglong
|
||||
ENV QL_HOME=/home/$QL_USER
|
||||
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
COPY --from=builder /usr/local/bin/. /usr/local/bin/
|
||||
|
||||
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
|
||||
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
|
||||
RUN set -x && \
|
||||
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install --no-install-recommends -y git \
|
||||
RUN set -x \
|
||||
&& apk update -f \
|
||||
&& apk upgrade \
|
||||
&& apk --no-cache add -f bash \
|
||||
coreutils \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
tzdata \
|
||||
perl \
|
||||
openssl \
|
||||
openssh-client \
|
||||
nodejs \
|
||||
jq \
|
||||
openssh \
|
||||
procps \
|
||||
netcat-openbsd \
|
||||
unzip \
|
||||
libatomic1 && \
|
||||
apt-get clean && \
|
||||
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
echo "Asia/Shanghai" >/etc/timezone && \
|
||||
git config --global user.email "qinglong@users.noreply.github.com" && \
|
||||
git config --global user.name "qinglong" && \
|
||||
git config --global http.postBuffer 524288000 && \
|
||||
npm install -g pnpm@8.3.1 pm2 ts-node && \
|
||||
rm -rf /root/.cache && \
|
||||
rm -rf /root/.npm && \
|
||||
rm -rf /etc/apt/apt.conf.d/docker-clean && \
|
||||
ulimit -c 0
|
||||
|
||||
RUN mkdir -p ${QL_DIR} && \
|
||||
chown -R ${QL_UID}:${QL_GID} ${QL_DIR}
|
||||
|
||||
USER qinglong
|
||||
npm \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& apk update \
|
||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||
&& git config --global user.email "qinglong@users.noreply.github.com" \
|
||||
&& git config --global user.name "qinglong" \
|
||||
&& git config --global http.postBuffer 524288000 \
|
||||
&& rm -rf /root/.cache \
|
||||
&& ulimit -c 0
|
||||
|
||||
ARG SOURCE_COMMIT
|
||||
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \
|
||||
cd ${QL_DIR} && \
|
||||
cp -f .env.example .env && \
|
||||
chmod 777 ${QL_DIR}/shell/*.sh && \
|
||||
chmod 777 ${QL_DIR}/docker/*.sh && \
|
||||
git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /tmp/static && \
|
||||
mkdir -p ${QL_DIR}/static && \
|
||||
cp -rf /tmp/static/* ${QL_DIR}/static && \
|
||||
rm -rf /tmp/static
|
||||
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
|
||||
&& cd ${QL_DIR} \
|
||||
&& cp -f .env.example .env \
|
||||
&& chmod 777 ${QL_DIR}/shell/*.sh \
|
||||
&& chmod 777 ${QL_DIR}/docker/*.sh \
|
||||
&& git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
|
||||
&& mkdir -p ${QL_DIR}/static \
|
||||
&& cp -rf /static/* ${QL_DIR}/static \
|
||||
&& rm -rf /static
|
||||
|
||||
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
||||
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
||||
@@ -96,17 +79,11 @@ ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOM
|
||||
|
||||
RUN pip3 install --prefix ${PYTHON_HOME} requests
|
||||
|
||||
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
||||
|
||||
USER root
|
||||
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
||||
|
||||
WORKDIR ${QL_DIR}
|
||||
|
||||
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
|
||||
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
|
||||
CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
|
||||
|
||||
VOLUME /ql/data
|
||||
|
||||
EXPOSE 5700
|
||||
|
||||
+42
-65
@@ -1,18 +1,13 @@
|
||||
FROM node:22-slim AS nodebuilder
|
||||
|
||||
FROM python:3.11-slim-bookworm AS builder
|
||||
FROM python:3.11-alpine3.18 AS builder
|
||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
|
||||
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
RUN set -x && \
|
||||
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
apt-get update && \
|
||||
apt-get install --no-install-recommends -y libatomic1 && \
|
||||
npm i -g pnpm@8.3.1 && \
|
||||
cd /tmp/build && \
|
||||
pnpm install --prod
|
||||
RUN set -x \
|
||||
&& apk update \
|
||||
&& apk add nodejs npm git \
|
||||
&& npm i -g pnpm@8.3.1 pm2 ts-node \
|
||||
&& cd /tmp/build \
|
||||
&& pnpm install --prod
|
||||
|
||||
FROM python:3.11-slim-bookworm
|
||||
FROM python:3.11-alpine
|
||||
|
||||
ARG QL_MAINTAINER="whyour"
|
||||
LABEL maintainer="${QL_MAINTAINER}"
|
||||
@@ -26,63 +21,51 @@ ENV QL_DIR=/ql \
|
||||
SHELL=/bin/bash \
|
||||
PS1="\u@\h:\w \$ "
|
||||
|
||||
ARG QL_UID=5432
|
||||
ARG QL_GID=5432
|
||||
RUN groupadd -g ${QL_GID} qinglong && \
|
||||
useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \
|
||||
mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \
|
||||
chmod 700 /home/qinglong/.ssh && \
|
||||
chown -R ${QL_UID}:${QL_GID} /home/qinglong
|
||||
VOLUME /ql/data
|
||||
|
||||
EXPOSE 5700
|
||||
|
||||
ENV QL_USER=qinglong
|
||||
ENV QL_HOME=/home/$QL_USER
|
||||
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
COPY --from=builder /usr/local/bin/. /usr/local/bin/
|
||||
|
||||
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
|
||||
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
|
||||
RUN set -x && \
|
||||
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \
|
||||
apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install --no-install-recommends -y git \
|
||||
RUN set -x \
|
||||
&& apk update -f \
|
||||
&& apk upgrade \
|
||||
&& apk --no-cache add -f bash \
|
||||
coreutils \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
tzdata \
|
||||
perl \
|
||||
openssl \
|
||||
openssh-client \
|
||||
nodejs \
|
||||
jq \
|
||||
openssh \
|
||||
procps \
|
||||
netcat-openbsd \
|
||||
unzip \
|
||||
libatomic1 && \
|
||||
apt-get clean && \
|
||||
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
echo "Asia/Shanghai" >/etc/timezone && \
|
||||
git config --global user.email "qinglong@users.noreply.github.com" && \
|
||||
git config --global user.name "qinglong" && \
|
||||
git config --global http.postBuffer 524288000 && \
|
||||
npm install -g pnpm@8.3.1 pm2 ts-node && \
|
||||
rm -rf /root/.cache && \
|
||||
rm -rf /root/.npm && \
|
||||
rm -rf /etc/apt/apt.conf.d/docker-clean && \
|
||||
ulimit -c 0
|
||||
npm \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& apk update \
|
||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||
&& git config --global user.email "qinglong@users.noreply.github.com" \
|
||||
&& git config --global user.name "qinglong" \
|
||||
&& git config --global http.postBuffer 524288000 \
|
||||
&& rm -rf /root/.cache \
|
||||
&& ulimit -c 0
|
||||
|
||||
RUN mkdir -p ${QL_DIR} && \
|
||||
chown -R ${QL_UID}:${QL_GID} ${QL_DIR}
|
||||
|
||||
USER qinglong
|
||||
ARG SOURCE_COMMIT
|
||||
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \
|
||||
cd ${QL_DIR} && \
|
||||
cp -f .env.example .env && \
|
||||
chmod 777 ${QL_DIR}/shell/*.sh && \
|
||||
chmod 777 ${QL_DIR}/docker/*.sh && \
|
||||
git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /tmp/static && \
|
||||
mkdir -p ${QL_DIR}/static && \
|
||||
cp -rf /tmp/static/* ${QL_DIR}/static && \
|
||||
rm -rf /tmp/static
|
||||
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
|
||||
&& cd ${QL_DIR} \
|
||||
&& cp -f .env.example .env \
|
||||
&& chmod 777 ${QL_DIR}/shell/*.sh \
|
||||
&& chmod 777 ${QL_DIR}/docker/*.sh \
|
||||
&& git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
|
||||
&& mkdir -p ${QL_DIR}/static \
|
||||
&& cp -rf /static/* ${QL_DIR}/static \
|
||||
&& rm -rf /static
|
||||
|
||||
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
||||
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
||||
@@ -96,17 +79,11 @@ ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOM
|
||||
|
||||
RUN pip3 install --prefix ${PYTHON_HOME} requests
|
||||
|
||||
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
||||
|
||||
USER root
|
||||
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
||||
|
||||
WORKDIR ${QL_DIR}
|
||||
|
||||
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
|
||||
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
|
||||
CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
|
||||
|
||||
VOLUME /ql/data
|
||||
|
||||
EXPOSE 5700
|
||||
|
||||
@@ -50,6 +50,6 @@ fi
|
||||
|
||||
log_with_style "SUCCESS" "🎉 容器启动成功!"
|
||||
|
||||
tail -f /dev/null
|
||||
crond -f >/dev/null
|
||||
|
||||
exec "$@"
|
||||
|
||||
+3
-19
@@ -1,17 +1,6 @@
|
||||
{
|
||||
"name": "@whyour/qinglong",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@8.3.1",
|
||||
"version": "2.20.2-3",
|
||||
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/whyour/qinglong.git"
|
||||
},
|
||||
"author": "whyour",
|
||||
"license": "Apache License 2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/whyour/qinglong/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "concurrently -n w: npm:start:*",
|
||||
"start:back": "nodemon ./back/app.ts",
|
||||
@@ -36,11 +25,6 @@
|
||||
"prettier --parser=typescript --write"
|
||||
]
|
||||
},
|
||||
"bin": {
|
||||
"ql": "shell/update.sh",
|
||||
"task": "shell/task.sh",
|
||||
"qinglong": "shell/start.sh"
|
||||
},
|
||||
"pnpm": {
|
||||
"peerDependencyRules": {
|
||||
"ignoreMissing": [
|
||||
@@ -93,9 +77,9 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"multer": "2.1.1",
|
||||
"node-schedule": "^2.1.0",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nodemailer": "^8.0.1",
|
||||
"p-queue-cjs": "7.3.4",
|
||||
"@bufbuild/protobuf": "^2.10.0",
|
||||
"ps-tree": "^1.2.0",
|
||||
|
||||
Generated
+568
-259
File diff suppressed because it is too large
Load Diff
+1
-9
@@ -9,15 +9,7 @@ else
|
||||
fi
|
||||
|
||||
echo -e "\n1、安装bot依赖...\n"
|
||||
os_name=$(source /etc/os-release && echo "$ID")
|
||||
if [[ $os_name == 'alpine' ]]; then
|
||||
apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev
|
||||
elif [[ $os_name == 'debian' ]] || [[ $os_name == 'ubuntu' ]]; then
|
||||
apt-get install -y gcc python3-dev musl-dev
|
||||
else
|
||||
echo -e "暂不支持此系统 $os_name"
|
||||
exit 1
|
||||
fi
|
||||
apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev
|
||||
echo -e "\nbot依赖安装成功...\n"
|
||||
|
||||
echo -e "2、下载bot所需文件...\n"
|
||||
|
||||
+8
-8
@@ -1,26 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
echo -e "开始发布"
|
||||
|
||||
echo -e "切换 debian 分支"
|
||||
git branch -D debian
|
||||
git checkout -b debian
|
||||
git push --set-upstream origin debian -f
|
||||
echo -e "切换master分支"
|
||||
git branch -D master
|
||||
git checkout -b master
|
||||
git push --set-upstream origin master -f
|
||||
|
||||
echo -e "更新cdn文件"
|
||||
ts-node-transpile-only sample/tool.ts
|
||||
|
||||
string=$(cat version.yaml | grep "version" | egrep "[^ ]*" -o | egrep "\d\.*")
|
||||
version="v$string"
|
||||
echo -e "当前版本$version-debian"
|
||||
echo -e "当前版本$version"
|
||||
|
||||
echo -e "删除已经存在的本地tag"
|
||||
git tag -d "$version-debian" &>/dev/null
|
||||
git tag -d "$version" &>/dev/null
|
||||
|
||||
echo -e "删除已经存在的远程tag"
|
||||
git push origin :refs/tags/$version-debian &>/dev/null
|
||||
git push origin :refs/tags/$version &>/dev/null
|
||||
|
||||
echo -e "创建新tag"
|
||||
git tag -a "$version-debian" -m "release $version-debian"
|
||||
git tag -a "$version" -m "release $version"
|
||||
|
||||
echo -e "提交tag"
|
||||
git push --tags
|
||||
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 前置依赖 nodejs、npm、python3
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ ! $QL_DIR ]]; then
|
||||
npm_dir=$(npm root -g)
|
||||
pnpm_dir=$(pnpm root -g)
|
||||
if [[ -d "$npm_dir/@whyour/qinglong" ]]; then
|
||||
QL_DIR="$npm_dir/@whyour/qinglong"
|
||||
elif [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then
|
||||
QL_DIR="$pnpm_dir/@whyour/qinglong"
|
||||
else
|
||||
echo -e "未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装"
|
||||
fi
|
||||
|
||||
if [[ $QL_DIR ]]; then
|
||||
echo -e "请先手动设置 export QL_DIR=$QL_DIR,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务"
|
||||
fi
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! $QL_DATA_DIR ]]; then
|
||||
echo -e "请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $QL_DATA_DIR != */data ]]; then
|
||||
echo -e "QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command="$1"
|
||||
|
||||
if [[ $command != "reload" ]]; then
|
||||
# 安装依赖
|
||||
os_name=$(source /etc/os-release && echo "$ID")
|
||||
|
||||
if [[ $os_name == 'alpine' ]]; then
|
||||
apk update
|
||||
apk add -f bash \
|
||||
coreutils \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
tzdata \
|
||||
perl \
|
||||
openssl \
|
||||
jq \
|
||||
nginx \
|
||||
openssh \
|
||||
procps \
|
||||
netcat-openbsd
|
||||
elif [[ $os_name == 'debian' ]] || [[ $os_name == 'ubuntu' ]]; then
|
||||
apt-get update
|
||||
apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client
|
||||
else
|
||||
echo -e "暂不支持此系统部署 $os_name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npm install -g pnpm@8.3.1 pm2 ts-node
|
||||
fi
|
||||
|
||||
export PYTHON_SHORT_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
export PNPM_HOME=${QL_DIR}/data/dep_cache/node
|
||||
export PYTHON_HOME=${QL_DIR}/data/dep_cache/python3
|
||||
export PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
|
||||
|
||||
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin
|
||||
export NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules
|
||||
export PIP_CACHE_DIR=${PYTHON_HOME}/pip
|
||||
export PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
|
||||
|
||||
if [[ $command != "reload" ]]; then
|
||||
pip3 install --prefix ${PYTHON_HOME} requests
|
||||
fi
|
||||
|
||||
cd ${QL_DIR}
|
||||
cp -f .env.example .env
|
||||
chmod 777 ${QL_DIR}/shell/*.sh
|
||||
|
||||
. ${QL_DIR}/shell/share.sh
|
||||
. ${QL_DIR}/shell/env.sh
|
||||
|
||||
log_with_style() {
|
||||
local level="$1"
|
||||
local message="$2"
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
|
||||
}
|
||||
|
||||
log_with_style "INFO" "🚀 1. 检测配置文件..."
|
||||
import_config "$@"
|
||||
make_dir /etc/nginx/conf.d
|
||||
make_dir /run/nginx
|
||||
fix_config
|
||||
|
||||
pm2 l &>/dev/null
|
||||
|
||||
log_with_style "INFO" "🔄 2. 启动 nginx..."
|
||||
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
|
||||
|
||||
log_with_style "INFO" "⚙️ 3. 启动 pm2 服务..."
|
||||
reload_pm2
|
||||
|
||||
if [[ $command != "reload" ]]; then
|
||||
if [[ $AutoStartBot == true ]]; then
|
||||
log_with_style "INFO" "🤖 4. 启动 bot..."
|
||||
nohup ql bot >$dir_log/bot.log 2>&1 &
|
||||
fi
|
||||
|
||||
if [[ $EnableExtraShell == true ]]; then
|
||||
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
|
||||
nohup ql extra >$dir_log/extra.log 2>&1 &
|
||||
fi
|
||||
|
||||
pm2 startup
|
||||
pm2 save
|
||||
fi
|
||||
|
||||
log_with_style "SUCCESS" "🎉 启动成功!"
|
||||
@@ -1,5 +1,5 @@
|
||||
import intl from 'react-intl-universal';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { BarChartOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import IconFont from '@/components/iconfont';
|
||||
import { BasicLayoutProps } from '@ant-design/pro-layout';
|
||||
|
||||
@@ -30,6 +30,12 @@ export default {
|
||||
icon: <IconFont type="ql-icon-crontab" />,
|
||||
component: '@/pages/crontab/index',
|
||||
},
|
||||
{
|
||||
path: '/statistics',
|
||||
name: intl.get('统计面板'),
|
||||
icon: <BarChartOutlined />,
|
||||
component: '@/pages/statistics/index',
|
||||
},
|
||||
{
|
||||
path: '/subscription',
|
||||
name: intl.get('订阅管理'),
|
||||
|
||||
@@ -18,6 +18,25 @@
|
||||
"青龙": "Qinglong",
|
||||
"返回首页": "Return to Home",
|
||||
"保存": "Save",
|
||||
"统计面板": "Statistics",
|
||||
"总体概览": "Overview",
|
||||
"总任务数量": "Total Tasks",
|
||||
"启用任务数": "Enabled Tasks",
|
||||
"禁用任务数": "Disabled Tasks",
|
||||
"今日总执行次数": "Today's Executions",
|
||||
"今日平均耗时(秒)": "Today's Avg Duration (s)",
|
||||
"近7日执行趋势": "7-Day Execution Trend",
|
||||
"今日平均耗时 Top 5": "Top 5 Slowest Today",
|
||||
"今日执行次数 Top 5": "Top 5 Most Frequent Today",
|
||||
"排名": "Rank",
|
||||
"任务名称": "Task Name",
|
||||
"平均耗时(秒)": "Avg Duration (s)",
|
||||
"最长单次(秒)": "Max Duration (s)",
|
||||
"今日执行次数": "Today's Count",
|
||||
"今日暂无执行记录": "No execution records today",
|
||||
"暂无数据": "No data",
|
||||
"次": "times",
|
||||
"刷新": "Refresh",
|
||||
"日志": "Log",
|
||||
"脚本": "Script",
|
||||
"确认保存文件": "Confirm to Save File",
|
||||
|
||||
@@ -18,6 +18,25 @@
|
||||
"青龙": "青龙",
|
||||
"返回首页": "返回首页",
|
||||
"保存": "保存",
|
||||
"统计面板": "统计面板",
|
||||
"总体概览": "总体概览",
|
||||
"总任务数量": "总任务数量",
|
||||
"启用任务数": "启用任务数",
|
||||
"禁用任务数": "禁用任务数",
|
||||
"今日总执行次数": "今日总执行次数",
|
||||
"今日平均耗时(秒)": "今日平均耗时(秒)",
|
||||
"近7日执行趋势": "近7日执行趋势",
|
||||
"今日平均耗时 Top 5": "今日平均耗时 Top 5",
|
||||
"今日执行次数 Top 5": "今日执行次数 Top 5",
|
||||
"排名": "排名",
|
||||
"任务名称": "任务名称",
|
||||
"平均耗时(秒)": "平均耗时(秒)",
|
||||
"最长单次(秒)": "最长单次(秒)",
|
||||
"今日执行次数": "今日执行次数",
|
||||
"今日暂无执行记录": "今日暂无执行记录",
|
||||
"暂无数据": "暂无数据",
|
||||
"次": "次",
|
||||
"刷新": "刷新",
|
||||
"日志": "日志",
|
||||
"脚本": "脚本",
|
||||
"确认保存文件": "确认保存文件",
|
||||
|
||||
@@ -64,7 +64,7 @@ const Diff = () => {
|
||||
const getFiles = () => {
|
||||
setLoading(true);
|
||||
request
|
||||
.get(`${config.apiPrefix}configs/samples`)
|
||||
.get(`${config.apiPrefix}configs/sample`)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
setFiles(data);
|
||||
|
||||
@@ -215,12 +215,12 @@ const Dependence = () => {
|
||||
<Form.Item
|
||||
label={intl.get('Linux 软件包镜像源')}
|
||||
name="linux"
|
||||
tooltip={intl.get('debian linux 镜像源')}
|
||||
tooltip={intl.get('alpine linux 镜像源')}
|
||||
>
|
||||
<Input.Group compact>
|
||||
<Input
|
||||
style={{ width: 250 }}
|
||||
placeholder={'http://mirrors.aliyun.com'}
|
||||
placeholder={'https://mirrors.aliyun.com'}
|
||||
value={systemConfig?.linuxMirror}
|
||||
onChange={(e) => {
|
||||
setSystemConfig({
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.stats-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.trend-chart-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trend-chart-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { SharedContext } from '@/layouts';
|
||||
import config from '@/utils/config';
|
||||
import { request } from '@/utils/http';
|
||||
import { BarChartOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { PageContainer } from '@ant-design/pro-layout';
|
||||
import { useOutletContext } from '@umijs/max';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Statistic,
|
||||
Table,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { ColumnProps } from 'antd/lib/table';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import './index.less';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
interface StatsData {
|
||||
total: number;
|
||||
enabled: number;
|
||||
disabled: number;
|
||||
today: {
|
||||
count: number;
|
||||
avgDuration: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface TrendItem {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface TopDurationItem {
|
||||
cron_id: number;
|
||||
cron_name: string;
|
||||
count: number;
|
||||
avgDuration: number;
|
||||
maxDuration: number;
|
||||
}
|
||||
|
||||
interface TopCountItem {
|
||||
cron_id: number;
|
||||
cron_name: string;
|
||||
count: number;
|
||||
avgDuration: number;
|
||||
}
|
||||
|
||||
const TrendChart = ({ data }: { data: TrendItem[] }) => {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="trend-chart-empty">
|
||||
{intl.get('暂无数据')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const width = 600;
|
||||
const height = 200;
|
||||
const paddingLeft = 40;
|
||||
const paddingRight = 20;
|
||||
const paddingTop = 20;
|
||||
const paddingBottom = 40;
|
||||
|
||||
const chartWidth = width - paddingLeft - paddingRight;
|
||||
const chartHeight = height - paddingTop - paddingBottom;
|
||||
|
||||
const maxCount = Math.max(...data.map((d) => d.count), 1);
|
||||
|
||||
const points = data.map((d, i) => ({
|
||||
x: paddingLeft + (i / Math.max(data.length - 1, 1)) * chartWidth,
|
||||
y: paddingTop + chartHeight - (d.count / maxCount) * chartHeight,
|
||||
...d,
|
||||
}));
|
||||
|
||||
const pathD = points
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
|
||||
.join(' ');
|
||||
|
||||
const areaD =
|
||||
pathD +
|
||||
` L ${points[points.length - 1].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)}` +
|
||||
` L ${points[0].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} Z`;
|
||||
|
||||
const yTicks = [0, Math.ceil(maxCount / 2), maxCount];
|
||||
|
||||
return (
|
||||
<div className="trend-chart-wrapper">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
style={{ width: '100%', height: 200 }}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{yTicks.map((tick) => {
|
||||
const y =
|
||||
paddingTop + chartHeight - (tick / maxCount) * chartHeight;
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={paddingLeft}
|
||||
y1={y}
|
||||
x2={paddingLeft + chartWidth}
|
||||
y2={y}
|
||||
stroke="#f0f0f0"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={paddingLeft - 6}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
fontSize={10}
|
||||
fill="#999"
|
||||
>
|
||||
{tick}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Area fill */}
|
||||
<path d={areaD} fill="rgba(24, 144, 255, 0.1)" />
|
||||
|
||||
{/* Line */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke="#1890ff"
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Points */}
|
||||
{points.map((p, i) => (
|
||||
<Tooltip
|
||||
key={i}
|
||||
title={`${p.date}: ${p.count} ${intl.get('次')}`}
|
||||
>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={4}
|
||||
fill="#1890ff"
|
||||
stroke="#fff"
|
||||
strokeWidth={2}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
{/* X axis labels */}
|
||||
{points.map((p, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={p.x}
|
||||
y={height - 8}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
fill="#999"
|
||||
>
|
||||
{p.date}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Axes */}
|
||||
<line
|
||||
x1={paddingLeft}
|
||||
y1={paddingTop}
|
||||
x2={paddingLeft}
|
||||
y2={paddingTop + chartHeight}
|
||||
stroke="#e8e8e8"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<line
|
||||
x1={paddingLeft}
|
||||
y1={paddingTop + chartHeight}
|
||||
x2={paddingLeft + chartWidth}
|
||||
y2={paddingTop + chartHeight}
|
||||
stroke="#e8e8e8"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Statistics = () => {
|
||||
const { headerStyle, isPhone } = useOutletContext<SharedContext>();
|
||||
const [stats, setStats] = useState<StatsData | null>(null);
|
||||
const [trend, setTrend] = useState<TrendItem[]>([]);
|
||||
const [topDuration, setTopDuration] = useState<TopDurationItem[]>([]);
|
||||
const [topCount, setTopCount] = useState<TopCountItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadAll = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [
|
||||
statsRes,
|
||||
trendRes,
|
||||
topDurationRes,
|
||||
topCountRes,
|
||||
] = await Promise.all([
|
||||
request.get(`${config.apiPrefix}crons/stats`),
|
||||
request.get(`${config.apiPrefix}crons/stats/trend`),
|
||||
request.get(`${config.apiPrefix}crons/stats/top-duration`),
|
||||
request.get(`${config.apiPrefix}crons/stats/top-count`),
|
||||
]);
|
||||
if (statsRes.code === 200) setStats(statsRes.data);
|
||||
if (trendRes.code === 200) setTrend(trendRes.data);
|
||||
if (topDurationRes.code === 200) setTopDuration(topDurationRes.data);
|
||||
if (topCountRes.code === 200) setTopCount(topCountRes.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, []);
|
||||
|
||||
const topDurationColumns: ColumnProps<TopDurationItem>[] = [
|
||||
{
|
||||
title: intl.get('排名'),
|
||||
key: 'rank',
|
||||
width: 60,
|
||||
render: (_: any, __: any, index: number) => index + 1,
|
||||
},
|
||||
{
|
||||
title: intl.get('任务名称'),
|
||||
dataIndex: 'cron_name',
|
||||
key: 'cron_name',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: intl.get('平均耗时(秒)'),
|
||||
dataIndex: 'avgDuration',
|
||||
key: 'avgDuration',
|
||||
width: 120,
|
||||
render: (v: number) => `${v}s`,
|
||||
},
|
||||
{
|
||||
title: intl.get('最长单次(秒)'),
|
||||
dataIndex: 'maxDuration',
|
||||
key: 'maxDuration',
|
||||
width: 120,
|
||||
render: (v: number) => `${v}s`,
|
||||
},
|
||||
];
|
||||
|
||||
const topCountColumns: ColumnProps<TopCountItem>[] = [
|
||||
{
|
||||
title: intl.get('排名'),
|
||||
key: 'rank',
|
||||
width: 60,
|
||||
render: (_: any, __: any, index: number) => index + 1,
|
||||
},
|
||||
{
|
||||
title: intl.get('任务名称'),
|
||||
dataIndex: 'cron_name',
|
||||
key: 'cron_name',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: intl.get('今日执行次数'),
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: intl.get('平均耗时(秒)'),
|
||||
dataIndex: 'avgDuration',
|
||||
key: 'avgDuration',
|
||||
width: 120,
|
||||
render: (v: number) => `${v}s`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
header={{
|
||||
style: headerStyle,
|
||||
}}
|
||||
title={
|
||||
<span>
|
||||
<BarChartOutlined style={{ marginRight: 8 }} />
|
||||
{intl.get('统计面板')}
|
||||
</span>
|
||||
}
|
||||
extra={[
|
||||
<Button
|
||||
key="refresh"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={loading}
|
||||
onClick={loadAll}
|
||||
>
|
||||
{intl.get('刷新')}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{/* Section 1: Overview Cards */}
|
||||
<Card
|
||||
className="stats-section"
|
||||
title={intl.get('总体概览')}
|
||||
loading={loading}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={8} md={6} lg={4}>
|
||||
<Statistic
|
||||
title={intl.get('总任务数量')}
|
||||
value={stats?.total ?? '-'}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={4}>
|
||||
<Statistic
|
||||
title={intl.get('启用任务数')}
|
||||
value={stats?.enabled ?? '-'}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={4}>
|
||||
<Statistic
|
||||
title={intl.get('禁用任务数')}
|
||||
value={stats?.disabled ?? '-'}
|
||||
valueStyle={{ color: '#d9d9d9' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={4}>
|
||||
<Statistic
|
||||
title={intl.get('今日总执行次数')}
|
||||
value={stats?.today?.count ?? '-'}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={4}>
|
||||
<Statistic
|
||||
title={intl.get('今日平均耗时(秒)')}
|
||||
value={stats?.today?.avgDuration ?? '-'}
|
||||
suffix="s"
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Section 2: 7-day Trend */}
|
||||
<Card
|
||||
className="stats-section"
|
||||
title={intl.get('近7日执行趋势')}
|
||||
loading={loading}
|
||||
>
|
||||
<TrendChart data={trend} />
|
||||
</Card>
|
||||
|
||||
{/* Section 3 & 4: Top Tables */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
className="stats-section"
|
||||
title={intl.get('今日平均耗时 Top 5')}
|
||||
loading={loading}
|
||||
>
|
||||
<Table
|
||||
dataSource={topDuration}
|
||||
columns={topDurationColumns}
|
||||
rowKey="cron_id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: intl.get('今日暂无执行记录') }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
className="stats-section"
|
||||
title={intl.get('今日执行次数 Top 5')}
|
||||
loading={loading}
|
||||
>
|
||||
<Table
|
||||
dataSource={topCount}
|
||||
columns={topCountColumns}
|
||||
rowKey="cron_id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: intl.get('今日暂无执行记录') }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Statistics;
|
||||
@@ -504,6 +504,7 @@ export default {
|
||||
'/login': intl.get('登录'),
|
||||
'/initialization': intl.get('初始化'),
|
||||
'/crontab': intl.get('定时任务'),
|
||||
'/statistics': intl.get('统计面板'),
|
||||
'/env': intl.get('环境变量'),
|
||||
'/subscription': intl.get('订阅管理'),
|
||||
'/config': intl.get('配置文件'),
|
||||
|
||||
Reference in New Issue
Block a user