Compare commits

..
32 Commits
Author SHA1 Message Date
Akizon77 589eae0d3f ci: 更新清理旧包的工作流配置 2025-07-28 04:20:26 +00:00
Akizon77 8e25bc9ce3 ci: 删除没有标签的版本 2025-07-28 04:15:24 +00:00
Akizon77 9aa4dcc206 ci: 更新 Docker 发布工作流 2025-07-28 04:12:23 +00:00
Akizon77 b2b5966699 fix: 修改 Ping 记录时间格式为 RFC3339 2025-07-26 07:20:34 +00:00
Akizon77 e9c7e0031c fix: #146 优化 Ping 记录的输出结构 2025-07-26 07:19:31 +00:00
Akizon77 1e3d29ffdb Merge branch 'main' of https://github.com/komari-monitor/komari 2025-07-26 07:10:15 +00:00
Akizon77 84670ac231 feat: 恢复备份添加锁、添加备份标记文件并在上传时验证 2025-07-26 06:56:02 +00:00
Akizon 6b17c5c498 Update README.md 2025-07-26 14:11:21 +08:00
Akizon eed9820ce6 Merge pull request #148 from dbedu/main
feat: 备份文件恢复
2025-07-26 10:06:35 +08:00
dbedu 858d914ae3 新功能: 新增备份文件恢复 2025-07-26 00:46:07 +08:00
Akizon be7c3812db docs: 更新文档链接 2025-07-25 01:00:17 +08:00
Akizon 44c65f1eb8 docs: 容器云标注价格 2025-07-25 00:36:50 +08:00
Akizon dd742c1038 docs: 添加雨云一键部署 2025-07-25 00:16:53 +08:00
Akizon d19b64ec1d Merge pull request #144 from komari-monitor/dev
feat: 优化静态资源服务
2025-07-24 16:25:33 +08:00
Akizon77 6ea22e3241 feat: 优化静态资源服务 2025-07-24 08:10:09 +00:00
Akizon77 ea99966e7a feat: 配置获取添加并发控制 2025-07-24 08:09:57 +00:00
Akizon77 fa13d4d1f9 feat: 自定义端口 2025-07-23 14:31:45 +00:00
Akizon77 15e4f583e0 chore: 删除待办清单文件 2025-07-23 14:25:40 +00:00
Akizon77 3fbbc7bdce docs: 添加前端构建说明文档 2025-07-23 14:23:54 +00:00
Akizon77 5fcd1d313c fix: #138 Docker容器添加时区数据 2025-07-23 04:00:24 +00:00
Akizon 967b7ab8a7 docs: 更新文档链接 2025-07-22 20:52:24 +08:00
Akizon77 d113dde3cc docs: 添加容器云一键部署 2025-07-21 07:52:12 +00:00
Akizon77 60c24a5ee1 docs: 添加一键脚本的指南 2025-07-21 06:48:51 +00:00
Akizon77 d5b8982763 docs: 添加Telegram群组链接 2025-07-21 06:40:45 +00:00
Akizon77 88a441da23 feat: 添加manifest.json路由 2025-07-20 08:28:15 +00:00
akizon77 03f27e7eb1 docs: 更新鸣谢 2025-07-19 01:01:56 +08:00
Akizon 6bba4526f0 Merge pull request #129 from komari-monitor/dev
feat: 定时自动续费
2025-07-16 11:26:14 +08:00
Akizon77 ebb84862a2 feat: 定时自动续费 2025-07-16 11:16:05 +08:00
Akizon77 28480a93d4 feat: 自动续费 2025-07-16 11:07:27 +08:00
Akizon 5c7c5a4e99 Merge pull request #128 from komari-monitor/main
Sync to dev branch
2025-07-16 10:21:57 +08:00
Akizon 90ee1da9c7 Merge pull request #127 from ours1505/dev
feat: 自动续费
2025-07-16 10:18:56 +08:00
Nya 596dbd0c2d feat: 自动续费 2025-07-16 10:02:26 +08:00
19 changed files with 591 additions and 121 deletions

No files matched your search

+29 -11
View File
@@ -20,23 +20,41 @@ jobs:
uses: actions/github-script@v7
with:
script: |
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
package_type: 'container',
package_name: process.env.GITHUB_REPOSITORY.split('/')[1]
});
// 分页获取所有包版本
const allVersions = [];
let page = 1;
let fetched;
const packageName = process.env.GITHUB_REPOSITORY.split('/')[1];
do {
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
package_type: 'container',
package_name: packageName,
page,
per_page: 100
});
fetched = packages.length;
allVersions.push(...packages);
page++;
} while (fetched === 100);
// Identify versions with only a single SHA tag (format: sha-<7 hexadecimal characters>)
const shaOnlyVersions = packages.filter(pkg => {
// 筛选仅有 SHA/sha256 标签或无标签的版本
const shaOrNoTagVersions = allVersions.filter(pkg => {
const tags = pkg.metadata.container.tags;
return tags.length === 1 && tags[0].match(/^sha-[0-9a-f]{7}$/);
return (
(tags.length === 1 && (
tags[0].match(/^sha-[0-9a-f]{7}$/) ||
tags[0].match(/^sha256:[0-9a-f]{64}$/)
)) ||
tags.length === 0
);
});
// Delete the SHA-only versions
for (const version of shaOnlyVersions) {
// 删除这些版本
for (const version of shaOrNoTagVersions) {
await github.rest.packages.deletePackageVersionForAuthenticatedUser({
package_type: 'container',
package_name: process.env.GITHUB_REPOSITORY.split('/')[1],
package_name: packageName,
package_version_id: version.id
});
console.log(`Deleted package version ${version.id} with SHA tag ${version.metadata.container.tags[0]}`);
console.log(`Deleted package version ${version.id} with tags: ${version.metadata.container.tags.join(', ') || 'none'}`);
}
+1 -1
View File
@@ -1,4 +1,4 @@
name: Publish Docker Image
name: Publish Docker Image on Main
on:
push:
+2
View File
@@ -6,6 +6,8 @@ WORKDIR /app
ARG TARGETOS
ARG TARGETARCH
RUN apk add --no-cache tzdata
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
RUN chmod +x /app/komari
+31 -9
View File
@@ -6,7 +6,7 @@
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
[文档](https://komari-monitor.github.io/komari-document/)
[文档](https://komari-document.pages.dev/) | [Telegram 群组](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
@@ -15,11 +15,25 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
## 快速开始
### 依赖
- Docker(快速部署)
- 或者 Go 1.18+ 和 Node.js 20+(手工构建)
### 0. 容器云一键部署
### Docker 部署
- 雨云云应用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. 使用一键安装脚本
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 创建数据目录:
```bash
mkdir -p ./data
@@ -41,7 +55,7 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
> [!NOTE]
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
### 二进制文件部署
### 3. 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
```bash
@@ -55,6 +69,11 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
### 手工构建
#### 依赖
- Go 1.18+ 和 Node.js 20+(手工构建)
1. 构建前端静态文件:
```bash
git clone https://github.com/komari-monitor/komari-web
@@ -78,17 +97,20 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
默认监听 `25774` 端口,访问 `http://localhost:25774`。
## 前端开发指南
[Komari 主题开发指南 | Komari](https://komari-monitor.github.io/komari-document/dev/theme.html)
[Komari 主题开发指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻译 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客户端 Agent 开发指南
[Komari Agent 信息上报与事件处理文档](https://komari-monitor.github.io/komari-document/dev/agent.html)
[Komari Agent 信息上报与事件处理文档](https://komari-document.pages.dev/dev/agent.html)
## 贡献
欢迎提交 Issue 或 Pull Request!
## 鸣谢
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感谢我自己能这么闲
- 广告位招租喵
- 提交PR、制作主题的各位开发者
## Star History
+18
View File
@@ -135,5 +135,23 @@ func DownloadBackup(c *gin.Context) {
log.Printf("Database file '%s' is within './data', skipping explicit addition.\n", dbFilePath)
}
// 添加备份标记文件
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + time.Now().Format("2006-01-02 15:04:05")
markupWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: "komari-backup-markup",
Method: zip.Deflate,
Modified: time.Now(),
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
return
}
_, err = markupWriter.Write([]byte(markupContent))
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
return
}
// zipWriter.Close() 由 defer 自动调用
}
+166
View File
@@ -0,0 +1,166 @@
package admin
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/cmd/flags"
)
// 只有一个备份恢复操作在进行
var restoreMutex sync.Mutex
// UploadBackup 用于接收上传的备份文件并将其内容恢复到原始位置
func UploadBackup(c *gin.Context) {
// 尝试获取锁,如果已有恢复操作在进行,则立即返回错误
if !restoreMutex.TryLock() {
api.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
return
}
defer restoreMutex.Unlock()
// 获取上传的文件
file, header, err := c.Request.FormFile("backup")
if err != nil {
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error getting uploaded file: %v", err))
return
}
defer file.Close()
// 检查文件是否为zip格式
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
api.RespondError(c, http.StatusBadRequest, "Uploaded file must be a ZIP archive")
return
}
// 创建临时文件保存上传的zip
tempFile, err := os.CreateTemp("", "backup-*.zip")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
return
}
tempFilePath := tempFile.Name()
defer os.Remove(tempFilePath) // 确保临时文件最终被删除
// 将上传的文件内容复制到临时文件
_, err = io.Copy(tempFile, file)
if err != nil {
tempFile.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
return
}
tempFile.Close() // 关闭文件以便后续操作
// 打开zip文件准备解压
zipReader, err := zip.OpenReader(tempFilePath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
return
}
defer zipReader.Close()
// 检查是否包含备份标记文件
hasMarkupFile := false
for _, zipFile := range zipReader.File {
if zipFile.Name == "komari-backup-markup" {
hasMarkupFile = true
break
}
}
if !hasMarkupFile {
api.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
return
}
// 确保data目录存在
if err := os.MkdirAll("./data", 0755); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
return
}
// 获取数据库文件名
dbFileName := filepath.Base(flags.DatabaseFile)
// 解压文件
for _, zipFile := range zipReader.File {
// 检查文件路径是否安全(防止路径遍历攻击)
filePath := zipFile.Name
if strings.Contains(filePath, "..") {
log.Printf("Potentially unsafe path in zip: %s, skipping", filePath)
continue
}
// 跳过备份标记文件
if filePath == "komari-backup-markup" {
continue
}
// 确定目标路径
var destPath string
if filePath == dbFileName {
// 如果是数据库文件,恢复到数据库文件路径
destPath = flags.DatabaseFile
} else {
// 其他文件恢复到data目录
destPath = filepath.Join("./data", filePath)
}
// 如果是目录,创建目录
if zipFile.FileInfo().IsDir() {
if err := os.MkdirAll(destPath, 0755); err != nil {
log.Printf("Error creating directory %s: %v", destPath, err)
}
continue
}
// 确保目标文件的目录存在
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
log.Printf("Error creating directory %s: %v", destDir, err)
continue
}
// 打开zip中的文件
srcFile, err := zipFile.Open()
if err != nil {
log.Printf("Error opening file from zip %s: %v", filePath, err)
continue
}
// 创建目标文件
destFile, err := os.Create(destPath)
if err != nil {
srcFile.Close()
log.Printf("Error creating file %s: %v", destPath, err)
continue
}
// 复制内容
_, err = io.Copy(destFile, srcFile)
srcFile.Close()
destFile.Close()
if err != nil {
log.Printf("Error extracting file %s: %v", destPath, err)
continue
}
// 保持原始文件的修改时间
if err := os.Chtimes(destPath, zipFile.Modified, zipFile.Modified); err != nil {
log.Printf("Error setting file time for %s: %v", destPath, err)
}
}
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Backup restored successfully",
})
}
+21 -4
View File
@@ -42,6 +42,16 @@ func GetRecordsByUUID(c *gin.Context) {
func GetPingRecords(c *gin.Context) {
uuid := c.Query("uuid")
hours := c.Query("hours")
type RecordsResp struct {
TaskId uint `json:"task_id"`
Time string `json:"time"`
Value int `json:"value"`
}
type Resp struct {
Count int `json:"count"`
Records []RecordsResp `json:"records"`
Tasks []gin.H `json:"tasks,omitempty"` // 任务列表
}
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
@@ -63,9 +73,16 @@ func GetPingRecords(c *gin.Context) {
return
}
response := gin.H{
"records": records,
"count": len(records),
response := &Resp{
Count: len(records),
Records: []RecordsResp{},
}
for _, r := range records {
response.Records = append(response.Records, RecordsResp{
TaskId: r.TaskId,
Time: r.Time.ToTime().Format(time.RFC3339),
Value: r.Value,
})
}
if len(records) > 0 {
@@ -103,7 +120,7 @@ func GetPingRecords(c *gin.Context) {
})
}
}
response["tasks"] = tasksList
response.Tasks = tasksList
}
api.RespondSuccess(c, response)
+1
View File
@@ -153,6 +153,7 @@ func RunServer() {
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
+4
View File
@@ -21,6 +21,10 @@ func Log(ip, uuid, message, msgType string) {
db.Create(logEntry)
}
func EventLog(eventType, message string) {
Log("", "", message, eventType)
}
// Delete logs older than 30 days
func RemoveOldLogs() {
db := dbcore.GetDBInstance()
+9
View File
@@ -3,6 +3,7 @@ package config
import (
"errors"
"log"
"sync"
"time"
"github.com/komari-monitor/komari/database/dbcore"
@@ -10,7 +11,15 @@ import (
"gorm.io/gorm"
)
var mu sync.Mutex
func init() {
mu = sync.Mutex{}
}
func Get() (models.Config, error) {
mu.Lock()
defer mu.Unlock()
db := dbcore.GetDBInstance()
var config models.Config
if err := db.First(&config).Error; err != nil {
+1
View File
@@ -29,6 +29,7 @@ type Client struct {
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt UTCTime `json:"expired_at" gorm:"type:timestamp"`
Group string `json:"group" gorm:"type:varchar(100)"`
+26 -6
View File
@@ -23,11 +23,14 @@ log_step() {
echo -e "${YELLOW}$1${NC}"
}
# Global variables
INSTALL_DIR="/opt/komari"
DATA_DIR="/opt/komari/data"
SERVICE_NAME="komari"
BINARY_PATH="$INSTALL_DIR/komari"
DEFAULT_PORT="25774"
LISTEN_PORT=""
# Show banner
show_banner() {
@@ -119,6 +122,21 @@ install_binary() {
return
fi
# 监听端口输入,校验范围 1-65535
while true; do
read -p "请输入监听端口 [默认: $DEFAULT_PORT]: " input_port
if [[ -z "$input_port" ]]; then
LISTEN_PORT="$DEFAULT_PORT"
break
elif [[ "$input_port" =~ ^[0-9]+$ ]] && (( input_port >= 1 && input_port <= 65535 )); then
LISTEN_PORT="$input_port"
break
else
log_error "端口号无效,请输入 1-65535 之间的数字。"
fi
done
install_dependencies
local arch=$(detect_arch)
@@ -147,13 +165,13 @@ install_binary() {
if ! check_systemd; then
log_step "警告:未检测到 systemd,跳过服务创建。"
log_step "您可以从命令行手动运行 Komari:"
log_step " $BINARY_PATH server -l 0.0.0.0:25774"
log_step " $BINARY_PATH server -l 0.0.0.0:$LISTEN_PORT"
echo
log_success "安装完成!"
return
fi
create_systemd_service
create_systemd_service "$LISTEN_PORT"
systemctl daemon-reload
systemctl enable ${SERVICE_NAME}.service
@@ -168,7 +186,7 @@ install_binary() {
if [ -z "$password" ]; then
log_error "未能获取初始密码,请检查日志"
fi
show_access_info "$password"
show_access_info "$password" "$LISTEN_PORT"
else
log_error "Komari 服务启动失败"
log_info "查看日志: journalctl -u ${SERVICE_NAME} -f"
@@ -178,6 +196,7 @@ install_binary() {
# Create systemd service file
create_systemd_service() {
local port="$1"
log_step "创建 systemd 服务..."
local service_file="/etc/systemd/system/${SERVICE_NAME}.service"
@@ -188,7 +207,7 @@ After=network.target
[Service]
Type=simple
ExecStart=${BINARY_PATH} server -l 0.0.0.0:25774
ExecStart=${BINARY_PATH} server -l 0.0.0.0:${port}
WorkingDirectory=${DATA_DIR}
Restart=always
User=root
@@ -203,13 +222,14 @@ EOF
# Show access information
show_access_info() {
local password=$1
local port=${2:-$DEFAULT_PORT}
echo
log_success "安装完成!"
echo
log_info "访问信息:"
log_info " URL: http://$(hostname -I | awk '{print $1}'):25774"
log_info " URL: http://$(hostname -I | awk '{print $1}'):${port}"
if [ -n "$password" ]; then
log_info "初始登录信息: $password"
log_info "初始登录信息(仅显示一次): $password"
fi
echo
log_info "服务管理命令:"
+62 -19
View File
@@ -103,6 +103,32 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
}
c.Data(http.StatusOK, "image/x-icon", data)
})
// r.GET("/manifest.json", func(c *gin.Context) {
// cfg, err := config.Get()
// if cfg.Theme == "default" || cfg.Theme == "" || err != nil {
// // 使用默认主题的manifest.json
// f, err := DistFS.Open("manifest.json")
// if err != nil {
// c.Status(http.StatusNotFound)
// return
// }
// defer f.Close()
// data, err := io.ReadAll(f)
// if err != nil {
// c.Status(http.StatusInternalServerError)
// return
// }
// c.Data(http.StatusOK, "application/json", data)
// } else {
// // 使用自定义主题的manifest.json
// themePath := filepath.Join("./data/theme", cfg.Theme, "dist", "manifest.json")
// if _, err := os.Stat(themePath); os.IsNotExist(err) {
// c.JSON(http.StatusNotFound, gin.H{"error": "Manifest not found"})
// } else {
// c.File(themePath)
// }
// }
// })
// Serve theme files from data/theme directory (for theme previews and static assets)
r.Static("/themes", "./data/theme")
@@ -133,26 +159,43 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
// serveFromEmbedded 从嵌入的文件系统服务文件
func serveFromEmbedded(c *gin.Context, path string) {
// 处理静态资源文件夹
folders := []string{"assets", "images", "streamer", "static"}
for _, folder := range folders {
if strings.HasPrefix(path, "/"+folder+"/") {
c.Header("Cache-Control", "public, max-age=15552000")
sub, err := fs.Sub(DistFS, folder)
if err == nil {
relativePath := strings.TrimPrefix(path, "/"+folder+"/")
file, err := sub.Open(relativePath)
if err == nil {
defer file.Close()
data, err := io.ReadAll(file)
if err == nil {
// 设置正确的Content-Type
contentType := getContentType(path)
c.Header("Content-Type", contentType)
c.Data(http.StatusOK, contentType, data)
return
}
}
// folders := []string{"assets", "images", "streamer", "static"}
// for _, folder := range folders {
// if strings.HasPrefix(path, "/"+folder+"/") {
// c.Header("Cache-Control", "public, max-age=15552000")
// sub, err := fs.Sub(DistFS, folder)
// if err == nil {
// relativePath := strings.TrimPrefix(path, "/"+folder+"/")
// file, err := sub.Open(relativePath)
// if err == nil {
// defer file.Close()
// data, err := io.ReadAll(file)
// if err == nil {
// // 设置正确的Content-Type
// contentType := getContentType(path)
// c.Header("Content-Type", contentType)
// c.Data(http.StatusOK, contentType, data)
// return
// }
// }
// }
// }
// }
// 如果文件存在,直接返回文件内容
cleanPath := strings.TrimPrefix(path, "/")
file, err := DistFS.Open(cleanPath)
if err == nil {
defer file.Close()
data, err := io.ReadAll(file)
if err == nil {
contentType := getContentType(path)
c.Header("Content-Type", contentType)
// 静态资源设置缓存
if strings.Contains(path, "/assets/") || strings.Contains(path, "/static/") {
c.Header("Cache-Control", "public, max-age=15552000")
}
c.Data(http.StatusOK, contentType, data)
return
}
}
+61
View File
@@ -0,0 +1,61 @@
# Frontend Build Instructions / 前端构建说明 / フロントエンド構築手順
## English
### Frontend Repository
- **Frontend project repository**: https://github.com/komari-monitor/komari-web
### Build Requirements
1. Clone the frontend repository and build the static files
2. Copy the generated static files to the `dist` folder in the Komari backend project root directory
3. Ensure the `dist` folder contains `index.html`
### Important Note
⚠️ **The projects under Akizon77's personal repository are no longer maintained. Please use the projects under the organization (komari-monitor).**
---
## 中文
### 前端项目仓库
- **前端项目地址**: https://github.com/komari-monitor/komari-web
### 构建要求
1. 克隆前端仓库并构建静态文件
2. 将生成的静态文件复制到 Komari 后端项目根目录下的 `dist` 文件夹
3. 确保 `dist` 文件夹内包含 `index.html`
### 重要提醒
⚠️ **Akizon77 个人仓库的项目已经不再使用,请使用组织(komari-monitor)下的项目。**
---
## 日本語
### フロントエンドプロジェクトリポジトリ
- **フロントエンドプロジェクトアドレス**: https://github.com/komari-monitor/komari-web
### ビルド要件
1. フロントエンドリポジトリをクローンして静的ファイルをビルドする
2. 生成された静的ファイルを Komari バックエンドプロジェクトのルートディレクトリ下の `dist` フォルダーにコピーする
3. `dist` フォルダー内に `index.html` が含まれていることを確認する
### 重要な注意事項
⚠️ **Akizon77 の個人リポジトリのプロジェクトは使用されなくなりました。組織(komari-monitor)下のプロジェクトを使用してください。**
---
## Quick Setup / 快速设置 / クイックセットアップ
```bash
# Clone frontend repository / 克隆前端仓库 / フロントエンドリポジトリをクローン
git clone https://github.com/komari-monitor/komari-web
cd komari-web
# Install dependencies and build / 安装依赖并构建 / 依存関係をインストールしてビルド
npm install
npm run build
# Copy dist folder to Komari backend project / 将 dist 文件夹复制到 Komari 后端项目 / dist フォルダーを Komari バックエンドプロジェクトにコピー
cp -r dist /path/to/komari/public/
```
-39
View File
@@ -1,39 +0,0 @@
# komari 待办清单
## 通用
- [ ] 低带宽模式
- [x] Web终端
- [x] 一键部署脚本
- [x] 远程命令执行
## Serser
- [x] Docker 部署
- [ ] Web前端
- [x] Web前端嵌入二进制
- [x] 命令行指令 chpasswd 强制更换密码
- [x] Github单点登录、绑定、解绑
- [x] 2FA
- [ ] 负载通知
- [x] 离线通知
- [ ] DDNS
- [x] Agent 备注
## Agent
- [x] 自动更新
- [x] 慢速模式(调整interval)
+7
View File
@@ -36,6 +36,13 @@ type MessageSender interface {
func SendTextMessage(message string, title string) error {
var err error
cfg, err := config.Get()
if err != nil {
return err
}
if !cfg.NotificationEnabled {
return nil
}
for i := 0; i < 3; i++ {
err = CurrentProvider.SendTextMessage(message, title)
if err == nil {
+35 -31
View File
@@ -8,6 +8,7 @@ import (
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
func CheckExpireScheduledWork() {
@@ -25,12 +26,6 @@ func CheckExpireScheduledWork() {
time.Sleep(time.Second)
continue
}
if !cfg.ExpireNotificationEnabled {
time.Sleep(time.Second)
continue
}
notificationLeadDays := cfg.ExpireNotificationLeadDays
clients_all, err := clients.GetAllClientBasicInfo()
if err != nil {
@@ -38,44 +33,53 @@ func CheckExpireScheduledWork() {
continue
}
type clientToExpireInfo struct {
Name string
DaysLeft int
}
var clientLeadToExpire []clientToExpireInfo
checkTime := time.Now()
for _, client := range clients_all {
clientExpireTime := client.ExpiredAt.ToTime()
// 过期提醒检查(仅当启用过期通知时)
if cfg.ExpireNotificationEnabled {
notificationLeadDays := cfg.ExpireNotificationLeadDays
if clientExpireTime.Before(checkTime) {
continue
type clientToExpireInfo struct {
Name string
DaysLeft int
}
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
var clientLeadToExpire []clientToExpireInfo
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
remainingDuration := clientExpireTime.Sub(checkTime)
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
for _, client := range clients_all {
clientExpireTime := client.ExpiredAt.ToTime()
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
Name: client.Name,
DaysLeft: daysLeft,
})
if clientExpireTime.Before(checkTime) {
continue
}
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
remainingDuration := clientExpireTime.Sub(checkTime)
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
Name: client.Name,
DaysLeft: daysLeft,
})
}
}
}
if len(clientLeadToExpire) > 0 {
message := "The following clients are about to expire: \n\n"
for _, clientInfo := range clientLeadToExpire {
message += fmt.Sprintf("%s in %d days.\n", clientInfo.Name, clientInfo.DaysLeft)
if len(clientLeadToExpire) > 0 {
message := "The following clients are about to expire: \n\n"
for _, clientInfo := range clientLeadToExpire {
message += fmt.Sprintf("• %s in %d days\n", clientInfo.Name, clientInfo.DaysLeft)
}
messageSender.SendTextMessage(message, "Komari Expiration Notification")
}
messageSender.SendTextMessage(message, "Komari Expiration Notification")
}
// 等待1秒,防止多次触发
time.Sleep(time.Second)
for _, client := range clients_all {
renewal.CheckAndAutoRenewal(client)
}
}
}
+3 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
// notificationState 保存单个客户端的通知状态。
@@ -119,7 +120,8 @@ func OnlineNotification(clientID string) {
if err != nil {
return
}
// 上线时检测续费
renewal.CheckAndAutoRenewal(client)
_, enabled := getNotificationConfig(clientID)
if !enabled {
return
+114
View File
@@ -0,0 +1,114 @@
package renewal
import (
"fmt"
"time"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/ws"
)
func CheckAndAutoRenewal(client models.Client) {
// 自动续费检查
type renewedClient struct {
Name string
NewExpireTime time.Time
}
var renewedClients []renewedClient
if !client.AutoRenewal {
return
}
// 不在线则不续费
if _, ok := ws.GetConnectedClients()[client.UUID]; !ok {
return
}
clientExpireTime := client.ExpiredAt.ToTime()
checkTime := time.Now()
// 如果到期时间小于0002年,跳过
if clientExpireTime.Year() < 2 {
return
}
// 检查是否已过期或当天过期
if clientExpireTime.Before(checkTime) || clientExpireTime.Format("2006-01-02") == checkTime.Format("2006-01-02") {
// 计算过期时间距离创建时间的总天数,判断是否为长期账单
now := time.Now()
hundredYearsFromNow := now.AddDate(100, 0, 0)
// 如果过期时间超过当前时间100年,视为长期/一次性账单,不续费
if clientExpireTime.After(hundredYearsFromNow) {
return
}
// 如果有账单周期且不为0,进行自动续费
if client.BillingCycle > 0 {
// 根据账单周期计算新的过期时间
var newExpireTime time.Time
billingCycle := client.BillingCycle
// 如果服务器的过期时间太早了,那么直接设置为从当前时间算的下一个到期时间
baseTime := clientExpireTime
if clientExpireTime.Before(now.AddDate(0, 0, -30)) { // 过期时间超过30天前
baseTime = now
}
if billingCycle >= 27 && billingCycle <= 32 {
// 月度计费 - 加1个月
newExpireTime = baseTime.AddDate(0, 1, 0)
} else if billingCycle >= 87 && billingCycle <= 95 {
// 季度计费 - 加3个月
newExpireTime = baseTime.AddDate(0, 3, 0)
} else if billingCycle >= 175 && billingCycle <= 185 {
// 半年计费 - 加6个月
newExpireTime = baseTime.AddDate(0, 6, 0)
} else if billingCycle >= 360 && billingCycle <= 370 {
// 年度计费 - 加1年
newExpireTime = baseTime.AddDate(1, 0, 0)
} else if billingCycle >= 720 && billingCycle <= 750 {
// 两年计费 - 加2年
newExpireTime = baseTime.AddDate(2, 0, 0)
} else if billingCycle >= 1080 && billingCycle <= 1150 {
// 三年计费 - 加3年
newExpireTime = baseTime.AddDate(3, 0, 0)
} else {
// 其他情况,直接加上账单周期天数
newExpireTime = baseTime.AddDate(0, 0, billingCycle)
}
// 更新客户端过期时间
updates := map[string]interface{}{
"uuid": client.UUID,
"expired_at": models.FromTime(newExpireTime),
}
err := clients.SaveClient(updates)
if err != nil {
auditlog.EventLog("renewal", fmt.Sprintf("Failed to renew client %s (%s): %v", client.Name, client.UUID, err))
return
}
renewedClients = append(renewedClients, renewedClient{
Name: client.Name,
NewExpireTime: newExpireTime,
})
auditlog.EventLog("renewal", fmt.Sprintf("Auto-renewed client: %s until %s",
client.Name, newExpireTime.Format("2006-01-02")))
}
}
// 发送续费通知
if len(renewedClients) > 0 {
message := "The following clients have been automatically renewed: \n\n"
for _, clientInfo := range renewedClients {
message += fmt.Sprintf("• %s until %s\n", clientInfo.Name, clientInfo.NewExpireTime.Format("2006-01-02"))
}
messageSender.SendTextMessage(message, "Komari Auto-Renewal Notification")
}
}