feat: audit log time-range filter + script execution schedules
Audit log time-range filter:
- audit_log_repo.py: unified query() with action/admin_username/date_from/date_to
filters + total count; _build_filters() helper for date parsing
- settings.py GET /audit/: add date_from, date_to, admin_username query params
- audit.html: date range inputs (YYYY-MM-DD), admin filter input, action filter
expanded with optgroups (服务器/推送/脚本/认证/设置); clear filter button
Script execution schedules:
- domain/models: PushSchedule extended with schedule_type('push'|'script'),
script_id FK, script_content Text, exec_timeout int, long_task bool;
source_path made nullable
- migrations.py: 6 new ALTER TABLE statements (idempotent)
- schedule_runner.py: detect schedule_type; for 'script' type use ScriptService;
for 'push' type keep original SyncEngineV2 sync_files behaviour
- schemas.py: ScheduleCreate/Update add all new fields
- schedules.html: full UI rewrite
- type selector radio: 📁 文件推送 / ⚡ 脚本执行
- script fields: script library dropdown + textarea + timeout + long_task
- schedule list shows type badge + detail preview
- saveSched() validates required fields per type
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+16
-3
@@ -195,11 +195,19 @@ class DbCredentialCreate(BaseModel):
|
||||
|
||||
class ScheduleCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
source_path: str = Field(..., min_length=1)
|
||||
server_ids: str = Field(..., min_length=3) # JSON array string
|
||||
server_ids: str = Field(..., min_length=3) # JSON array string
|
||||
cron_expr: str = Field(..., min_length=9, max_length=100)
|
||||
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
|
||||
enabled: bool = True
|
||||
# Schedule type
|
||||
schedule_type: str = Field("push", pattern="^(push|script)$")
|
||||
# Push-specific (required when schedule_type == 'push')
|
||||
source_path: Optional[str] = None
|
||||
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
|
||||
# Script-specific (required when schedule_type == 'script')
|
||||
script_id: Optional[int] = None
|
||||
script_content: Optional[str] = None
|
||||
exec_timeout: int = Field(60, ge=10, le=600)
|
||||
long_task: bool = False
|
||||
|
||||
|
||||
class ScheduleUpdate(BaseModel):
|
||||
@@ -209,6 +217,11 @@ class ScheduleUpdate(BaseModel):
|
||||
cron_expr: Optional[str] = None
|
||||
sync_mode: Optional[str] = Field(None, pattern="^(incremental|full|overwrite|checksum)$")
|
||||
enabled: Optional[bool] = None
|
||||
schedule_type: Optional[str] = Field(None, pattern="^(push|script)$")
|
||||
script_id: Optional[int] = None
|
||||
script_content: Optional[str] = None
|
||||
exec_timeout: Optional[int] = Field(None, ge=10, le=600)
|
||||
long_task: Optional[bool] = None
|
||||
|
||||
|
||||
# ── Preset ──
|
||||
|
||||
+12
-6
@@ -288,18 +288,24 @@ audit_router = APIRouter(prefix="/api/audit", tags=["audit"])
|
||||
@audit_router.get("/", response_model=dict)
|
||||
async def list_audit_logs(
|
||||
action: Optional[str] = None,
|
||||
admin_username: Optional[str] = None,
|
||||
date_from: Optional[str] = None, # ISO date: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS
|
||||
date_to: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List audit logs with pagination"""
|
||||
"""List audit logs with pagination, action filter, admin filter, and date range."""
|
||||
repo = AuditLogRepositoryImpl(db)
|
||||
total = await repo.count(action)
|
||||
if action:
|
||||
logs = await repo.get_by_action(action, limit, offset)
|
||||
else:
|
||||
logs = await repo.get_recent(limit, offset)
|
||||
logs, total = await repo.query(
|
||||
action=action,
|
||||
admin_username=admin_username,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
|
||||
@@ -103,37 +103,83 @@ async def schedule_runner_loop():
|
||||
if not server_ids:
|
||||
continue
|
||||
|
||||
# Execute sync via SyncEngineV2
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
||||
schedule_type = getattr(schedule, 'schedule_type', 'push') or 'push'
|
||||
|
||||
engine = SyncEngineV2(
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
sync_log_repo=SyncLogRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
retry_repo=PushRetryJobRepositoryImpl(session),
|
||||
)
|
||||
if schedule_type == 'script':
|
||||
# ── Script execution schedule ──
|
||||
from server.application.services.script_service import ScriptService
|
||||
from server.infrastructure.database.script_repo import (
|
||||
ScriptRepositoryImpl, ScriptExecutionRepositoryImpl,
|
||||
)
|
||||
from server.infrastructure.database.db_credential_repo import DbCredentialRepositoryImpl
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
|
||||
result = await engine.sync_files(
|
||||
server_ids=server_ids,
|
||||
source_path=schedule.source_path,
|
||||
sync_mode=getattr(schedule, 'sync_mode', None) or "incremental",
|
||||
trigger_type="schedule",
|
||||
operator=f"schedule:{schedule.name}",
|
||||
)
|
||||
cmd = getattr(schedule, 'script_content', None) or ''
|
||||
script_id = getattr(schedule, 'script_id', None)
|
||||
|
||||
# If no inline command, load from script library
|
||||
if not cmd and script_id:
|
||||
script = await ScriptRepositoryImpl(session).get_by_id(script_id)
|
||||
if script:
|
||||
cmd = script.content or ''
|
||||
|
||||
if not cmd:
|
||||
logger.error(f"Schedule '{schedule.name}': no command or script content, skipping")
|
||||
continue
|
||||
|
||||
svc = ScriptService(
|
||||
script_repo=ScriptRepositoryImpl(session),
|
||||
execution_repo=ScriptExecutionRepositoryImpl(session),
|
||||
credential_repo=DbCredentialRepositoryImpl(session),
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
)
|
||||
timeout = getattr(schedule, 'exec_timeout', None) or 60
|
||||
long_task = bool(getattr(schedule, 'long_task', False))
|
||||
execution = await svc.execute_command(
|
||||
command=cmd,
|
||||
server_ids=server_ids,
|
||||
script_id=script_id,
|
||||
timeout=timeout,
|
||||
operator=f"schedule:{schedule.name}",
|
||||
long_task=long_task,
|
||||
)
|
||||
logger.info(
|
||||
f"Schedule '{schedule.name}' (script) triggered: "
|
||||
f"execution_id={execution.id} on {len(server_ids)} servers"
|
||||
)
|
||||
else:
|
||||
# ── File push schedule (original behaviour) ──
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
||||
|
||||
engine = SyncEngineV2(
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
sync_log_repo=SyncLogRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
retry_repo=PushRetryJobRepositoryImpl(session),
|
||||
)
|
||||
|
||||
result = await engine.sync_files(
|
||||
server_ids=server_ids,
|
||||
source_path=schedule.source_path or '/tmp',
|
||||
sync_mode=getattr(schedule, 'sync_mode', None) or "incremental",
|
||||
trigger_type="schedule",
|
||||
operator=f"schedule:{schedule.name}",
|
||||
)
|
||||
logger.info(
|
||||
f"Schedule '{schedule.name}' (push) triggered: "
|
||||
f"{result['completed']}/{result['total']} succeeded"
|
||||
)
|
||||
|
||||
# Update last_run_at
|
||||
schedule.last_run_at = now
|
||||
await session.commit()
|
||||
|
||||
triggered += 1
|
||||
logger.info(
|
||||
f"Schedule '{schedule.name}' triggered: "
|
||||
f"{result['completed']}/{result['total']} succeeded"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Schedule {schedule.id} execution failed: {e}")
|
||||
|
||||
@@ -205,15 +205,31 @@ class PasswordPreset(Base):
|
||||
|
||||
|
||||
class PushSchedule(Base):
|
||||
"""Scheduled push — cron expression trigger"""
|
||||
"""Scheduled task — cron-triggered file push OR script execution."""
|
||||
__tablename__ = "push_schedules"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="调度名称")
|
||||
source_path = Column(String(500), nullable=False)
|
||||
|
||||
# ── Schedule type ──
|
||||
# 'push': rsync file sync (default, original behaviour)
|
||||
# 'script': execute a shell command / script via Agent
|
||||
schedule_type = Column(String(20), default="push", comment="调度类型: push | script")
|
||||
|
||||
# ── Push-specific fields (schedule_type = 'push') ──
|
||||
source_path = Column(String(500), nullable=True, comment="推送源目录(push 类型必填)")
|
||||
sync_mode = Column(String(20), default="incremental", comment="同步模式: incremental/full/overwrite/checksum")
|
||||
|
||||
# ── Script-specific fields (schedule_type = 'script') ──
|
||||
script_id = Column(Integer, ForeignKey("scripts.id", ondelete="SET NULL"), nullable=True,
|
||||
comment="脚本库引用(script 类型,可留空使用 script_content)")
|
||||
script_content = Column(Text, nullable=True, comment="临时命令内容(script 类型,不保存到脚本库)")
|
||||
exec_timeout = Column(Integer, default=60, comment="执行超时(秒)")
|
||||
long_task = Column(Boolean, default=False, comment="长任务 nohup 模式")
|
||||
|
||||
# ── Common fields ──
|
||||
server_ids = Column(Text, nullable=False, comment="JSON数组: 目标服务器ID列表")
|
||||
cron_expr = Column(String(100), nullable=False, comment="cron表达式: 分 时 日 月 周")
|
||||
sync_mode = Column(String(20), default="incremental", comment="同步模式: incremental/full/overwrite/checksum")
|
||||
enabled = Column(Boolean, default=True)
|
||||
last_run_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
"""Nexus — AuditLog Repository (Async SQLAlchemy)"""
|
||||
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import AuditLog
|
||||
|
||||
|
||||
def _build_filters(
|
||||
action: Optional[str] = None,
|
||||
admin_username: Optional[str] = None,
|
||||
date_from: Optional[str] = None, # ISO date or datetime string
|
||||
date_to: Optional[str] = None,
|
||||
) -> list:
|
||||
"""Build SQLAlchemy WHERE clauses from optional filter params."""
|
||||
filters = []
|
||||
if action:
|
||||
filters.append(AuditLog.action == action)
|
||||
if admin_username:
|
||||
filters.append(AuditLog.admin_username == admin_username)
|
||||
if date_from:
|
||||
try:
|
||||
dt = datetime.fromisoformat(date_from)
|
||||
# Treat bare date (YYYY-MM-DD) as start of day UTC
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
filters.append(AuditLog.created_at >= dt.replace(tzinfo=None))
|
||||
except ValueError:
|
||||
pass
|
||||
if date_to:
|
||||
try:
|
||||
dt = datetime.fromisoformat(date_to)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
filters.append(AuditLog.created_at <= dt.replace(tzinfo=None))
|
||||
except ValueError:
|
||||
pass
|
||||
return filters
|
||||
|
||||
|
||||
class AuditLogRepositoryImpl:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
@@ -17,25 +50,38 @@ class AuditLogRepositoryImpl:
|
||||
await self.session.refresh(log)
|
||||
return log
|
||||
|
||||
async def query(
|
||||
self,
|
||||
action: Optional[str] = None,
|
||||
admin_username: Optional[str] = None,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> Tuple[List[AuditLog], int]:
|
||||
"""Unified query with all filters + total count."""
|
||||
filters = _build_filters(action, admin_username, date_from, date_to)
|
||||
count_q = select(func.count(AuditLog.id))
|
||||
data_q = select(AuditLog).order_by(AuditLog.created_at.desc()).offset(offset).limit(limit)
|
||||
if filters:
|
||||
count_q = count_q.where(*filters)
|
||||
data_q = data_q.where(*filters)
|
||||
total = int((await self.session.execute(count_q)).scalar() or 0)
|
||||
rows = list((await self.session.execute(data_q)).scalars().all())
|
||||
return rows, total
|
||||
|
||||
# ── Kept for backward compat ──
|
||||
async def count(self, action: Optional[str] = None) -> int:
|
||||
filters = _build_filters(action)
|
||||
q = select(func.count(AuditLog.id))
|
||||
if action:
|
||||
q = q.where(AuditLog.action == action)
|
||||
result = await self.session.execute(q)
|
||||
return result.scalar() or 0
|
||||
if filters:
|
||||
q = q.where(*filters)
|
||||
return int((await self.session.execute(q)).scalar() or 0)
|
||||
|
||||
async def get_recent(self, limit: int = 200, offset: int = 0) -> List[AuditLog]:
|
||||
result = await self.session.execute(
|
||||
select(AuditLog).order_by(AuditLog.created_at.desc()).offset(offset).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
rows, _ = await self.query(limit=limit, offset=offset)
|
||||
return rows
|
||||
|
||||
async def get_by_action(self, action: str, limit: int = 50, offset: int = 0) -> List[AuditLog]:
|
||||
result = await self.session.execute(
|
||||
select(AuditLog)
|
||||
.where(AuditLog.action == action)
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
rows, _ = await self.query(action=action, limit=limit, offset=offset)
|
||||
return rows
|
||||
@@ -112,6 +112,13 @@ async def run_schema_migrations():
|
||||
"ALTER TABLE push_schedules ADD COLUMN sync_mode VARCHAR(20) DEFAULT 'incremental' COMMENT '同步模式'",
|
||||
"ALTER TABLE admins ADD COLUMN token_version INT DEFAULT 0 COMMENT '令牌版本(重用时递增使旧token失效)'",
|
||||
"ALTER TABLE script_executions ADD COLUMN credential_id INT NULL COMMENT 'DB凭据替换$DB_*变量'",
|
||||
# Script-schedule columns (push_schedules extended to support script execution)
|
||||
"ALTER TABLE push_schedules ADD COLUMN schedule_type VARCHAR(20) DEFAULT 'push' COMMENT '调度类型: push|script'",
|
||||
"ALTER TABLE push_schedules ADD COLUMN script_id INT NULL COMMENT '脚本库引用'",
|
||||
"ALTER TABLE push_schedules ADD COLUMN script_content TEXT NULL COMMENT '临时命令内容'",
|
||||
"ALTER TABLE push_schedules ADD COLUMN exec_timeout INT DEFAULT 60 COMMENT '执行超时(秒)'",
|
||||
"ALTER TABLE push_schedules ADD COLUMN long_task TINYINT(1) DEFAULT 0 COMMENT '长任务nohup模式'",
|
||||
"ALTER TABLE push_schedules MODIFY COLUMN source_path VARCHAR(500) NULL COMMENT '推送源目录(push类型)'",
|
||||
]
|
||||
async with AsyncSessionLocal() as session:
|
||||
for sql in migrations:
|
||||
|
||||
+69
-19
@@ -4,27 +4,63 @@
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">审计日志</h1></div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<select id="actionFilter" onchange="_offset=0;loadAudit()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部操作</option>
|
||||
<option value="create_server">添加服务器</option>
|
||||
<option value="update_server">更新服务器</option>
|
||||
<option value="delete_server">删除服务器</option>
|
||||
<option value="sync_files">文件推送</option>
|
||||
<option value="sync_commands">命令执行</option>
|
||||
<option value="update_setting">修改设置</option>
|
||||
<option value="login">登录</option>
|
||||
<option value="retry_job">重试任务</option>
|
||||
<option value="delete_retry">删除重试</option>
|
||||
<option value="execute_started">脚本执行开始</option>
|
||||
<option value="execute_command">脚本执行命令</option>
|
||||
<option value="stop_execution">停止脚本执行</option>
|
||||
<option value="retry_execution">重试脚本执行</option>
|
||||
<option value="mark_stuck">标记脚本卡住</option>
|
||||
<option value="auto_stuck">自动检测卡住</option>
|
||||
<option value="server_auto_stuck">单台服务器卡住</option>
|
||||
<option value="script_job_callback">长任务回调</option>
|
||||
<optgroup label="服务器">
|
||||
<option value="create_server">添加服务器</option>
|
||||
<option value="update_server">更新服务器</option>
|
||||
<option value="delete_server">删除服务器</option>
|
||||
<option value="install_agent">安装 Agent</option>
|
||||
<option value="generate_agent_key">生成 Agent Key</option>
|
||||
</optgroup>
|
||||
<optgroup label="推送 / 同步">
|
||||
<option value="sync_files">文件推送</option>
|
||||
<option value="sync_preview">推送预览(dry-run)</option>
|
||||
<option value="sync_commands">命令批量执行</option>
|
||||
<option value="file_delete">远程文件删除</option>
|
||||
<option value="file_rename">远程文件重命名</option>
|
||||
<option value="file_mkdir">远程新建目录</option>
|
||||
<option value="browse_directory">目录浏览</option>
|
||||
</optgroup>
|
||||
<optgroup label="脚本执行">
|
||||
<option value="execute_started">脚本执行开始</option>
|
||||
<option value="execute_command">脚本执行命令</option>
|
||||
<option value="stop_execution">停止脚本执行</option>
|
||||
<option value="retry_execution">重试脚本执行</option>
|
||||
<option value="mark_stuck">标记脚本卡住</option>
|
||||
<option value="auto_stuck">自动检测卡住</option>
|
||||
<option value="server_auto_stuck">单台服务器卡住</option>
|
||||
<option value="script_job_callback">长任务回调</option>
|
||||
</optgroup>
|
||||
<optgroup label="认证 / 安全">
|
||||
<option value="login_success">登录成功</option>
|
||||
<option value="login_locked">登录锁定</option>
|
||||
<option value="logout">登出</option>
|
||||
<option value="change_password">修改密码</option>
|
||||
<option value="setup_totp">TOTP 设置</option>
|
||||
<option value="enable_totp">启用 TOTP</option>
|
||||
<option value="disable_totp">禁用 TOTP</option>
|
||||
<option value="reveal_api_key">查看 API Key</option>
|
||||
<option value="refresh_reuse_attack">Token 重用攻击</option>
|
||||
</optgroup>
|
||||
<optgroup label="系统设置">
|
||||
<option value="update_setting">修改设置</option>
|
||||
<option value="create_schedule">新建调度</option>
|
||||
<option value="update_schedule">更新调度</option>
|
||||
<option value="delete_schedule">删除调度</option>
|
||||
<option value="retry_job">重试任务</option>
|
||||
<option value="delete_retry">删除重试</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<input id="adminFilter" type="text" placeholder="操作人" onchange="_offset=0;loadAudit()"
|
||||
class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm w-28 placeholder-slate-500">
|
||||
<input id="dateFrom" type="date" onchange="_offset=0;loadAudit()"
|
||||
class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<span class="text-slate-600 text-xs">至</span>
|
||||
<input id="dateTo" type="date" onchange="_offset=0;loadAudit()"
|
||||
class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<button onclick="clearFilters()" class="px-2 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded-lg transition" title="清除日期过滤">✕</button>
|
||||
<button onclick="_offset=0;loadAudit()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -50,9 +86,23 @@
|
||||
let _offset=0;
|
||||
let _total=0;
|
||||
|
||||
function clearFilters(){
|
||||
document.getElementById('dateFrom').value='';
|
||||
document.getElementById('dateTo').value='';
|
||||
document.getElementById('adminFilter').value='';
|
||||
_offset=0;loadAudit();
|
||||
}
|
||||
|
||||
async function loadAudit(){
|
||||
const action=document.getElementById('actionFilter').value;
|
||||
const url=API+'/audit/?limit='+PAGE_SIZE+'&offset='+_offset+(action?'&action='+encodeURIComponent(action):'');
|
||||
const adminUser=document.getElementById('adminFilter').value.trim();
|
||||
const dateFrom=document.getElementById('dateFrom').value;
|
||||
const dateTo=document.getElementById('dateTo').value;
|
||||
let url=API+'/audit/?limit='+PAGE_SIZE+'&offset='+_offset;
|
||||
if(action)url+='&action='+encodeURIComponent(action);
|
||||
if(adminUser)url+='&admin_username='+encodeURIComponent(adminUser);
|
||||
if(dateFrom)url+='&date_from='+encodeURIComponent(dateFrom);
|
||||
if(dateTo)url+='&date_to='+encodeURIComponent(dateTo+'T23:59:59');
|
||||
const r=await apiFetch(url);if(!r)return;
|
||||
const data=await r.json();
|
||||
_total=data.total||0;
|
||||
|
||||
+186
-27
@@ -8,16 +8,69 @@
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="schedList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
<div id="schedModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
|
||||
<!-- Modal -->
|
||||
<div id="schedModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50 py-6 overflow-y-auto">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-4 my-auto">
|
||||
<h2 id="schedModalTitle" class="text-white font-semibold text-lg">新建调度</h2>
|
||||
<input type="hidden" id="editSchedId">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="sName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="每日推送"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">源路径</label><input id="sPath" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/project/"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">Cron 表达式</label><input id="sCron" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="0 2 * * *"><div class="text-slate-600 text-xs mt-1">分 时 日 月 周 · 例: 0 2 * * * = 每天凌晨2点</div></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">目标服务器</label><select id="sServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-32"></select></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">同步模式</label><select id="sSyncMode" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="incremental">增量同步</option><option value="full">全量同步</option><option value="checksum">校验和</option></select></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveSched()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideSchedModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
|
||||
<!-- Basic -->
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称 *</label><input id="sName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="每日更新"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">Cron 表达式 *</label><input id="sCron" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="0 2 * * *"><div class="text-slate-600 text-xs mt-1">分 时 日 月 周 · 0 2 * * * = 每天凌晨 2 点 · 最小精度 1 分钟</div></div>
|
||||
|
||||
<!-- Type selector -->
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">调度类型 *</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sType" value="push" checked class="accent-brand" onchange="onTypeChange()">
|
||||
<span class="text-sm">📁 文件推送 (rsync)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sType" value="script" class="accent-brand" onchange="onTypeChange()">
|
||||
<span class="text-sm">⚡ 脚本执行</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Push fields -->
|
||||
<div id="pushFields" class="space-y-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">源路径 *</label><input id="sPath" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/project/"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">同步模式</label><select id="sSyncMode" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="incremental">增量同步</option><option value="full">全量同步(--delete)</option><option value="checksum">校验和</option></select></div>
|
||||
</div>
|
||||
|
||||
<!-- Script fields -->
|
||||
<div id="scriptFields" class="hidden space-y-3">
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">脚本库选择(可选)</label>
|
||||
<select id="sScriptId" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="">不使用脚本库(使用下方命令)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">命令内容(脚本库为空时生效)</label>
|
||||
<textarea id="sScriptContent" rows="4" placeholder="echo 'scheduled task' uptime" class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-green-400 text-sm font-mono resize-y"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">超时(秒)</label><input id="sTimeout" type="number" value="60" min="10" max="600" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
||||
<div class="flex items-center pt-5"><label class="flex items-center gap-2 cursor-pointer"><input type="checkbox" id="sLongTask" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"><span class="text-sm text-slate-300">长任务(nohup)</span></label></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Common: server selector -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-slate-400 text-xs">目标服务器 *</label>
|
||||
<button onclick="Array.from(document.getElementById('sServers').options).forEach(o=>o.selected=true)" class="text-brand-light text-xs hover:underline">全选</button>
|
||||
</div>
|
||||
<select id="sServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-32"></select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button onclick="saveSched()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm font-medium">保存</button>
|
||||
<button onclick="hideSchedModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -27,39 +80,145 @@
|
||||
<script>
|
||||
initLayout('schedules');
|
||||
let _schedsCache={};
|
||||
let _scriptLibCache=[];
|
||||
|
||||
function onTypeChange(){
|
||||
const t=document.querySelector('input[name="sType"]:checked')?.value||'push';
|
||||
document.getElementById('pushFields').classList.toggle('hidden',t!=='push');
|
||||
document.getElementById('scriptFields').classList.toggle('hidden',t!=='script');
|
||||
}
|
||||
|
||||
async function loadScheds(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/schedules/');if(!r)return;const scheds=await r.json();
|
||||
_schedsCache={};scheds.forEach(s=>_schedsCache[s.id]=s);
|
||||
document.getElementById('schedList').innerHTML=scheds.length?scheds.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="toggleSched(${s.id},${!s.enabled})" class="w-10 h-6 rounded-full transition-colors ${s.enabled?'bg-brand':'bg-slate-700'} relative shrink-0" title="${s.enabled?'点击禁用':'点击启用'}"><span class="absolute top-0.5 ${s.enabled?'right-0.5':'left-0.5'} w-5 h-5 bg-white rounded-full shadow transition-all"></span></button>
|
||||
<div><span class="text-white font-medium">${esc(s.name)}</span><div class="text-slate-500 text-xs mt-0.5"><span class="font-mono">${esc(s.cron_expr)}</span> · ${esc(s.sync_mode||'incremental')}${s.last_run_at?' · 上次: '+fmtTime(s.last_run_at):''}</div></div>
|
||||
const TYPE_LABEL={push:'📁 推送',script:'⚡ 脚本'};
|
||||
document.getElementById('schedList').innerHTML=scheds.length?scheds.map(s=>{
|
||||
const isScript=(s.schedule_type||'push')==='script';
|
||||
const detail=isScript
|
||||
?`${esc(s.script_content&&s.script_content.substring(0,60)||'(脚本库 #'+(s.script_id||'?')+')').replace(/\n/g,' ')}...`
|
||||
:`${esc(s.source_path||'')} · ${esc(s.sync_mode||'incremental')}`;
|
||||
return `<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<button onclick="toggleSched(${s.id},${!s.enabled})" class="w-10 h-6 rounded-full transition-colors ${s.enabled?'bg-brand':'bg-slate-700'} relative shrink-0" title="${s.enabled?'点击禁用':'点击启用'}">
|
||||
<span class="absolute top-0.5 ${s.enabled?'right-0.5':'left-0.5'} w-5 h-5 bg-white rounded-full shadow transition-all"></span>
|
||||
</button>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white font-medium">${esc(s.name)}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 text-slate-400">${TYPE_LABEL[s.schedule_type||'push']||'推送'}</span>
|
||||
</div>
|
||||
<div class="text-slate-500 text-xs mt-0.5 truncate">
|
||||
<span class="font-mono">${esc(s.cron_expr)}</span> · ${detail}${s.last_run_at?' · 上次: '+fmtTime(s.last_run_at):''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 ml-3">
|
||||
<button onclick="showEditSched(${s.id})" class="text-slate-400 hover:text-white text-xs transition">编辑</button>
|
||||
<button onclick="deleteSched(${s.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2"><button onclick="showEditSched(${s.id})" class="text-slate-400 hover:text-white text-xs transition">编辑</button><button onclick="deleteSched(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无调度</div>';
|
||||
</div>`}).join(''):'<div class="text-slate-500 text-center py-8">暂无调度</div>';
|
||||
}catch(e){document.getElementById('schedList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
async function loadServersIntoSelect(selectedIds){
|
||||
const r=await apiFetch(API+'/servers/');if(!r)return;const data=await r.json();const servers=data.items||data;
|
||||
document.getElementById('sServers').innerHTML=servers.map(s=>`<option value="${s.id}" ${selectedIds&&selectedIds.includes(s.id)?'selected':''}>${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
const r=await apiFetch(API+'/servers/?per_page=200');if(!r)return;const data=await r.json();const servers=data.items||data;
|
||||
document.getElementById('sServers').innerHTML=servers.map(s=>`<option value="${s.id}"${selectedIds&&selectedIds.includes(s.id)?' selected':''}>${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
}
|
||||
function showAddSched(){document.getElementById('editSchedId').value='';document.getElementById('schedModalTitle').textContent='新建调度';document.getElementById('sName').value='';document.getElementById('sPath').value='';document.getElementById('sCron').value='';document.getElementById('sSyncMode').value='incremental';loadServersIntoSelect([]);document.getElementById('schedModal').classList.remove('hidden')}
|
||||
async function showEditSched(id){const s=_schedsCache[id];if(!s)return;document.getElementById('editSchedId').value=s.id;document.getElementById('schedModalTitle').textContent='编辑调度';document.getElementById('sName').value=s.name||'';document.getElementById('sPath').value=s.source_path||'';document.getElementById('sCron').value=s.cron_expr||'';document.getElementById('sSyncMode').value=s.sync_mode||'incremental';let serverIds=[];try{serverIds=JSON.parse(s.server_ids||'[]')}catch(e){}await loadServersIntoSelect(serverIds);document.getElementById('schedModal').classList.remove('hidden')}
|
||||
|
||||
async function loadScriptsIntoSelect(selectedId){
|
||||
if(!_scriptLibCache.length){
|
||||
const r=await apiFetch(API+'/scripts/');if(!r)return;
|
||||
const scripts=await r.json();_scriptLibCache=scripts;
|
||||
}
|
||||
const sel=document.getElementById('sScriptId');
|
||||
sel.innerHTML='<option value="">不使用脚本库(使用下方命令)</option>'+
|
||||
_scriptLibCache.map(s=>`<option value="${s.id}"${s.id==selectedId?' selected':''}>${esc(s.name)}</option>`).join('');
|
||||
}
|
||||
|
||||
function showAddSched(){
|
||||
document.getElementById('editSchedId').value='';
|
||||
document.getElementById('schedModalTitle').textContent='新建调度';
|
||||
document.getElementById('sName').value='';document.getElementById('sPath').value='';
|
||||
document.getElementById('sCron').value='';document.getElementById('sSyncMode').value='incremental';
|
||||
document.getElementById('sScriptContent').value='';document.getElementById('sTimeout').value='60';
|
||||
document.getElementById('sLongTask').checked=false;
|
||||
document.querySelector('input[name="sType"][value="push"]').checked=true;
|
||||
onTypeChange();
|
||||
Promise.all([loadServersIntoSelect([]),loadScriptsIntoSelect(null)]);
|
||||
document.getElementById('schedModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function showEditSched(id){
|
||||
const s=_schedsCache[id];if(!s)return;
|
||||
document.getElementById('editSchedId').value=s.id;
|
||||
document.getElementById('schedModalTitle').textContent='编辑调度';
|
||||
document.getElementById('sName').value=s.name||'';
|
||||
document.getElementById('sPath').value=s.source_path||'';
|
||||
document.getElementById('sCron').value=s.cron_expr||'';
|
||||
document.getElementById('sSyncMode').value=s.sync_mode||'incremental';
|
||||
document.getElementById('sScriptContent').value=s.script_content||'';
|
||||
document.getElementById('sTimeout').value=s.exec_timeout||60;
|
||||
document.getElementById('sLongTask').checked=!!s.long_task;
|
||||
const stype=(s.schedule_type||'push');
|
||||
document.querySelector(`input[name="sType"][value="${stype}"]`).checked=true;
|
||||
onTypeChange();
|
||||
let serverIds=[];try{serverIds=JSON.parse(s.server_ids||'[]')}catch(e){}
|
||||
await Promise.all([loadServersIntoSelect(serverIds),loadScriptsIntoSelect(s.script_id)]);
|
||||
document.getElementById('schedModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideSchedModal(){document.getElementById('schedModal').classList.add('hidden')}
|
||||
|
||||
async function saveSched(){
|
||||
const id=document.getElementById('editSchedId').value;const opts=document.getElementById('sServers').selectedOptions;const serverIds=Array.from(opts).map(o=>parseInt(o.value));
|
||||
const body={name:document.getElementById('sName').value,source_path:document.getElementById('sPath').value,cron_expr:document.getElementById('sCron').value,server_ids:JSON.stringify(serverIds),sync_mode:document.getElementById('sSyncMode').value||'incremental',enabled:true};
|
||||
if(!body.name||!body.cron_expr){alert('名称和Cron必填');return}
|
||||
if(id){const r=await apiFetch(API+'/schedules/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','调度已更新');else toast('error','更新失败')}else{const r=await apiFetch(API+'/schedules/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','调度已创建');else toast('error','创建失败')}
|
||||
hideSchedModal();loadScheds();
|
||||
const id=document.getElementById('editSchedId').value;
|
||||
const opts=document.getElementById('sServers').selectedOptions;
|
||||
const serverIds=Array.from(opts).map(o=>parseInt(o.value));
|
||||
if(serverIds.length===0){toast('warning','请选择目标服务器');return}
|
||||
const name=document.getElementById('sName').value.trim();
|
||||
const cronExpr=document.getElementById('sCron').value.trim();
|
||||
if(!name||!cronExpr){toast('warning','名称和 Cron 表达式为必填项');return}
|
||||
const stype=document.querySelector('input[name="sType"]:checked')?.value||'push';
|
||||
|
||||
const body={name,cron_expr:cronExpr,server_ids:JSON.stringify(serverIds),schedule_type:stype,enabled:true};
|
||||
|
||||
if(stype==='push'){
|
||||
body.source_path=document.getElementById('sPath').value.trim();
|
||||
body.sync_mode=document.getElementById('sSyncMode').value||'incremental';
|
||||
if(!body.source_path){toast('warning','推送调度需要填写源路径');return}
|
||||
}else{
|
||||
const scriptId=document.getElementById('sScriptId').value;
|
||||
const content=document.getElementById('sScriptContent').value.trim();
|
||||
if(!scriptId&&!content){toast('warning','脚本调度需要选择脚本库或填写命令内容');return}
|
||||
if(scriptId)body.script_id=parseInt(scriptId);
|
||||
if(content)body.script_content=content;
|
||||
body.exec_timeout=parseInt(document.getElementById('sTimeout').value)||60;
|
||||
body.long_task=document.getElementById('sLongTask').checked;
|
||||
}
|
||||
|
||||
const method=id?'PUT':'POST';
|
||||
const url=id?API+'/schedules/'+id:API+'/schedules/';
|
||||
const r=await apiFetch(url,{method,headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(r&&r.ok){toast('success',id?'调度已更新':'调度已创建');hideSchedModal();loadScheds();}
|
||||
else{const e=await r?.json().catch(()=>({detail:'保存失败'}));toast('error',e.detail||'保存失败')}
|
||||
}
|
||||
async function toggleSched(id,enabled){const r=await apiFetch(API+'/schedules/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({enabled})});if(r&&r.ok)toast('info',enabled?'已启用':'已禁用');loadScheds()}
|
||||
async function deleteSched(id){if(!confirm('确定删除调度?'))return;const r=await apiFetch(API+'/schedules/'+id,{method:'DELETE'});if(r&&r.ok)toast('success','已删除');else toast('error','删除失败');loadScheds()}
|
||||
|
||||
async function toggleSched(id,enabled){
|
||||
const r=await apiFetch(API+'/schedules/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({enabled})});
|
||||
if(r&&r.ok)toast('info',enabled?'已启用':'已禁用');loadScheds();
|
||||
}
|
||||
|
||||
async function deleteSched(id){
|
||||
if(!confirm('确定删除此调度?'))return;
|
||||
const r=await apiFetch(API+'/schedules/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','已删除');else toast('error','删除失败');loadScheds();
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
|
||||
loadScheds();
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
Reference in New Issue
Block a user