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:
Your Name
2026-05-23 17:29:16 +08:00
parent 4ca07fa252
commit d414b945e1
8 changed files with 442 additions and 99 deletions
+16 -3
View File
@@ -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
View File
@@ -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,
+70 -24
View File
@@ -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}")
+19 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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'&#10;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>