2929PANEL_VERSION = os .getenv ("PANEL_VERSION" , "1.0.0" )
3030STATIC_ASSET_VERSION = hashlib .sha256 (
3131 (APP_DIR / "static" / "app.css" ).read_bytes ()
32+ + (APP_DIR / "static" / "panel-overrides.css" ).read_bytes ()
3233 + (APP_DIR / "static" / "app.js" ).read_bytes ()
3334).hexdigest ()[:12 ]
3435MINECRAFT_VERSION_MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
5758CONTROL_LOG_FILE = DATA_DIR / "server-control.log"
5859BACKUP_CONFIG_FILE = DATA_DIR / "backup-config.json"
5960BACKUP_STATUS_FILE = DATA_DIR / "backup-status.json"
61+ RESTART_SCHEDULE_FILE = DATA_DIR / "restart-schedule.json"
6062PANEL_UPDATE_STATUS_FILE = DATA_DIR / "panel-update-status.json"
6163SESSION_COOKIE = "techtim_session"
6264INSTALL_LOCK = threading .Lock ()
6365INSTALL_ACTIVE = False
6466SERVER_STDIN_LOCK = threading .Lock ()
65- BACKUP_LOCK = threading .Lock ()
67+ MAINTENANCE_LOCK = threading .Lock ()
68+ BACKUP_LOCK = MAINTENANCE_LOCK
6669BACKUP_ACTIVE = False
70+ RESTART_SCHEDULE_LOCK = threading .Lock ()
71+ RESTART_OPERATION_LOCK = MAINTENANCE_LOCK
72+ RESTART_OPERATION_ACTIVE = False
73+ RESTART_SCHEDULER_STOP = threading .Event ()
74+ RESTART_SCHEDULER_THREAD : threading .Thread | None = None
6775PANEL_UPDATE_LOCK = threading .Lock ()
6876PANEL_UPDATE_ACTIVE = False
6977PANEL_UPDATE_CHECK_LOCK = threading .Lock ()
@@ -137,6 +145,11 @@ class BackupConfigRequest(BaseModel):
137145 retention_count : int = Field (default = 7 , ge = 1 , le = 50 )
138146
139147
148+ class RestartScheduleRequest (BaseModel ):
149+ enabled : bool = False
150+ restart_time : str = Field (default = "04:00" , pattern = r"^(?:[01]\d|2[0-3]):[0-5]\d$" )
151+
152+
140153class ConfigRequest (BaseModel ):
141154 Type : str = "PAPER"
142155 Version : str = "LATEST"
@@ -542,7 +555,7 @@ def prune_backup_archives(retention_count: int) -> None:
542555def claim_backup_operation () -> bool :
543556 global BACKUP_ACTIVE
544557 with BACKUP_LOCK :
545- if BACKUP_ACTIVE :
558+ if BACKUP_ACTIVE or RESTART_OPERATION_ACTIVE :
546559 return False
547560 BACKUP_ACTIVE = True
548561 return True
@@ -694,6 +707,160 @@ def backup_scheduler() -> None:
694707 write_backup_status ("failed" , f"자동 백업 스케줄 확인 실패: { clean_log (str (error ))} " )
695708
696709
710+ def default_restart_schedule () -> dict [str , Any ]:
711+ return {
712+ "enabled" : False ,
713+ "restart_time" : "04:00" ,
714+ "last_run_date" : "" ,
715+ "last_run_at" : "" ,
716+ "last_result" : "not_run" ,
717+ "last_message" : "예약 재시작 실행 기록이 없습니다." ,
718+ }
719+
720+
721+ def normalize_restart_schedule (raw : Any ) -> dict [str , Any ]:
722+ defaults = default_restart_schedule ()
723+ stored = raw if isinstance (raw , dict ) else {}
724+ restart_time = str (stored .get ("restart_time" ) or defaults ["restart_time" ]).strip ()
725+ if not re .fullmatch (r"(?:[01]\d|2[0-3]):[0-5]\d" , restart_time ):
726+ restart_time = defaults ["restart_time" ]
727+ return {
728+ "enabled" : bool (stored .get ("enabled" , defaults ["enabled" ])),
729+ "restart_time" : restart_time ,
730+ "last_run_date" : str (stored .get ("last_run_date" ) or "" ),
731+ "last_run_at" : str (stored .get ("last_run_at" ) or "" ),
732+ "last_result" : str (stored .get ("last_result" ) or defaults ["last_result" ]),
733+ "last_message" : str (stored .get ("last_message" ) or defaults ["last_message" ]),
734+ }
735+
736+
737+ def read_restart_schedule () -> dict [str , Any ]:
738+ with RESTART_SCHEDULE_LOCK :
739+ return normalize_restart_schedule (read_json (RESTART_SCHEDULE_FILE , {}))
740+
741+
742+ def write_restart_schedule (schedule : dict [str , Any ]) -> dict [str , Any ]:
743+ normalized = normalize_restart_schedule (schedule )
744+ ensure_dirs ()
745+ with RESTART_SCHEDULE_LOCK :
746+ write_json (RESTART_SCHEDULE_FILE , normalized )
747+ return normalized
748+
749+
750+ def restart_schedule_response (schedule : dict [str , Any ] | None = None ) -> dict [str , Any ]:
751+ current = schedule or read_restart_schedule ()
752+ next_run_at = ""
753+ if current ["enabled" ]:
754+ hour , minute = (int (part ) for part in current ["restart_time" ].split (":" ))
755+ now = datetime .now (KST )
756+ target = now .replace (hour = hour , minute = minute , second = 0 , microsecond = 0 )
757+ if target <= now or current .get ("last_run_date" ) == now .date ().isoformat ():
758+ target += timedelta (days = 1 )
759+ next_run_at = target .isoformat (timespec = "minutes" )
760+ return {
761+ ** current ,
762+ "timezone" : "Asia/Seoul" ,
763+ "next_run_at" : next_run_at ,
764+ "active" : restart_operation_active (),
765+ }
766+
767+
768+ def restart_operation_active () -> bool :
769+ with RESTART_OPERATION_LOCK :
770+ return RESTART_OPERATION_ACTIVE
771+
772+
773+ def claim_restart_operation () -> bool :
774+ global RESTART_OPERATION_ACTIVE
775+ with RESTART_OPERATION_LOCK :
776+ if RESTART_OPERATION_ACTIVE or BACKUP_ACTIVE :
777+ return False
778+ RESTART_OPERATION_ACTIVE = True
779+ return True
780+
781+
782+ def release_restart_operation () -> None :
783+ global RESTART_OPERATION_ACTIVE
784+ with RESTART_OPERATION_LOCK :
785+ RESTART_OPERATION_ACTIVE = False
786+
787+
788+ def update_restart_schedule_result (result : str , message : str ) -> None :
789+ with RESTART_SCHEDULE_LOCK :
790+ schedule = normalize_restart_schedule (read_json (RESTART_SCHEDULE_FILE , {}))
791+ schedule ["last_result" ] = result
792+ schedule ["last_message" ] = message
793+ write_json (RESTART_SCHEDULE_FILE , schedule )
794+
795+
796+ def claim_due_restart (now : datetime ) -> dict [str , Any ] | None :
797+ with RESTART_SCHEDULE_LOCK :
798+ schedule = normalize_restart_schedule (read_json (RESTART_SCHEDULE_FILE , {}))
799+ today = now .date ().isoformat ()
800+ if (
801+ not schedule ["enabled" ]
802+ or now .strftime ("%H:%M" ) != schedule ["restart_time" ]
803+ or schedule ["last_run_date" ] == today
804+ ):
805+ return None
806+ schedule ["last_run_date" ] = today
807+ schedule ["last_run_at" ] = now .isoformat (timespec = "seconds" )
808+ schedule ["last_result" ] = "running"
809+ schedule ["last_message" ] = "예약된 Minecraft 서버 재시작을 처리하고 있습니다."
810+ write_json (RESTART_SCHEDULE_FILE , schedule )
811+ return schedule
812+
813+
814+ def run_scheduled_restart_if_due () -> None :
815+ schedule = claim_due_restart (datetime .now (KST ))
816+ if not schedule :
817+ return
818+ if not claim_restart_operation ():
819+ message = "백업 또는 복원 작업이 진행 중이어서 이번 예약 재시작을 건너뛰었습니다."
820+ append_log (CONTROL_LOG_FILE , message )
821+ update_restart_schedule_result ("skipped" , message )
822+ return
823+
824+ try :
825+ container = get_container ()
826+ if not container :
827+ message = "예약 시각에 게임 서버 컨테이너가 없어 재시작을 건너뛰었습니다."
828+ append_log (CONTROL_LOG_FILE , message )
829+ update_restart_schedule_result ("skipped" , message )
830+ return
831+ container .reload ()
832+ if container .status != "running" :
833+ message = "예약 시각에 Minecraft 서버가 실행 중이 아니어서 재시작을 건너뛰었습니다."
834+ append_log (CONTROL_LOG_FILE , message )
835+ update_restart_schedule_result ("skipped" , message )
836+ return
837+
838+ append_log (
839+ CONTROL_LOG_FILE ,
840+ f"매일 { schedule ['restart_time' ]} 한국표준시 예약에 따라 Minecraft 게임 컨테이너를 재시작합니다." ,
841+ )
842+ try :
843+ send_backup_console_command (container , "save-all flush" )
844+ time .sleep (2 )
845+ except (docker .errors .DockerException , OSError , RuntimeError ) as error :
846+ append_log (CONTROL_LOG_FILE , f"예약 재시작 전 월드 저장 확인 필요: { clean_log (str (error ))} " )
847+ container .restart (timeout = 60 )
848+ message = "예약된 Minecraft 게임 컨테이너 재시작이 완료되었습니다."
849+ append_log (CONTROL_LOG_FILE , message )
850+ update_restart_schedule_result ("success" , message )
851+ except Exception as error :
852+ message = f"예약 재시작 중 오류가 발생했습니다: { clean_log (str (error ))} "
853+ append_log (CONTROL_LOG_FILE , message )
854+ update_restart_schedule_result ("error" , message )
855+ finally :
856+ release_restart_operation ()
857+
858+
859+ def restart_scheduler_loop () -> None :
860+ while not RESTART_SCHEDULER_STOP .wait (5 ):
861+ run_scheduled_restart_if_due ()
862+
863+
697864def public_server_ip () -> str :
698865 configured = str (os .getenv ("PUBLIC_IP" ) or "" ).strip ()
699866 if configured :
@@ -1361,9 +1528,25 @@ def login_html(change: bool = False) -> str:
13611528
13621529
13631530@app .on_event ("startup" )
1364- def start_backup_scheduler () -> None :
1531+ def start_background_schedulers () -> None :
1532+ global RESTART_SCHEDULER_THREAD
13651533 ensure_dirs ()
13661534 threading .Thread (target = backup_scheduler , daemon = True , name = "minecraft-backup-scheduler" ).start ()
1535+ if not RESTART_SCHEDULER_THREAD or not RESTART_SCHEDULER_THREAD .is_alive ():
1536+ RESTART_SCHEDULER_STOP .clear ()
1537+ RESTART_SCHEDULER_THREAD = threading .Thread (
1538+ target = restart_scheduler_loop ,
1539+ daemon = True ,
1540+ name = "minecraft-restart-scheduler" ,
1541+ )
1542+ RESTART_SCHEDULER_THREAD .start ()
1543+
1544+
1545+ @app .on_event ("shutdown" )
1546+ def stop_background_schedulers () -> None :
1547+ RESTART_SCHEDULER_STOP .set ()
1548+ if RESTART_SCHEDULER_THREAD and RESTART_SCHEDULER_THREAD .is_alive ():
1549+ RESTART_SCHEDULER_THREAD .join (timeout = 6 )
13671550
13681551
13691552@app .get ("/login" , response_class = HTMLResponse )
@@ -1596,6 +1779,8 @@ def delete_server_icon(request: Request):
15961779@app .post ("/api/server/start" )
15971780def start_server (payload : StartServerRequest , request : Request ):
15981781 require_auth (request )
1782+ if restart_operation_active ():
1783+ raise HTTPException (status_code = 409 , detail = "예약 재시작이 완료된 후 서버를 시작해주세요." )
15991784 if backup_operation_active ():
16001785 raise HTTPException (status_code = 409 , detail = "백업 또는 복원 작업 중에는 서버를 시작할 수 없습니다." )
16011786 if not payload .eula_accepted :
@@ -1657,6 +1842,8 @@ def start_server(payload: StartServerRequest, request: Request):
16571842@app .post ("/api/server/stop" )
16581843def stop_server (request : Request ):
16591844 require_auth (request )
1845+ if restart_operation_active ():
1846+ raise HTTPException (status_code = 409 , detail = "예약 재시작이 완료된 후 서버를 중지해주세요." )
16601847 if backup_operation_active ():
16611848 raise HTTPException (status_code = 409 , detail = "백업 또는 복원 작업이 끝난 후 서버를 중지해주세요." )
16621849 container = get_container ()
@@ -1684,6 +1871,8 @@ def stop_server(request: Request):
16841871@app .post ("/api/server/restart" )
16851872def restart_server (request : Request ):
16861873 require_auth (request )
1874+ if restart_operation_active ():
1875+ raise HTTPException (status_code = 409 , detail = "예약 재시작이 이미 진행 중입니다." )
16871876 if backup_operation_active ():
16881877 raise HTTPException (status_code = 409 , detail = "백업 또는 복원 작업이 끝난 후 서버를 재시작해주세요." )
16891878 container = get_container ()
@@ -1797,9 +1986,7 @@ def server_resources(request: Request):
17971986 return resources
17981987
17991988
1800- @app .get ("/api/server/log" )
1801- def server_log (request : Request ):
1802- require_auth (request )
1989+ def read_server_log () -> str :
18031990 container = get_container ()
18041991 logs = ""
18051992 if container :
@@ -1810,7 +1997,21 @@ def server_log(request: Request):
18101997 control = clean_log (CONTROL_LOG_FILE .read_text (encoding = "utf-8" )) if CONTROL_LOG_FILE .exists () else ""
18111998 if control :
18121999 logs = f"[패널 제어 로그]\n { control .rstrip ()} \n \n { logs .lstrip ()} " .strip ()
1813- return {"log" : logs }
2000+ return logs
2001+
2002+
2003+ @app .get ("/api/server/log" )
2004+ def server_log (request : Request ):
2005+ require_auth (request )
2006+ return {"log" : read_server_log ()}
2007+
2008+
2009+ @app .get ("/api/log" )
2010+ def combined_log (request : Request ):
2011+ require_auth (request )
2012+ install = clean_log (INSTALL_LOG_FILE .read_text (encoding = "utf-8" )).strip () if INSTALL_LOG_FILE .exists () else ""
2013+ server = read_server_log ().strip ()
2014+ return {"log" : "\n \n " .join (part for part in (install , server ) if part )}
18142015
18152016
18162017@app .post ("/api/server/command" )
@@ -2025,6 +2226,42 @@ def delete_backup(filename: str, request: Request):
20252226 return {"status" : "deleted" , "message" : "백업 파일을 삭제했습니다." }
20262227
20272228
2229+ @app .get ("/api/restart-schedule" )
2230+ def get_restart_schedule (request : Request ):
2231+ require_auth (request )
2232+ return {
2233+ "status" : "ok" ,
2234+ "schedule" : restart_schedule_response (),
2235+ }
2236+
2237+
2238+ @app .post ("/api/restart-schedule" )
2239+ def save_restart_schedule (payload : RestartScheduleRequest , request : Request ):
2240+ require_auth (request )
2241+ existing = read_restart_schedule ()
2242+ changed = (
2243+ existing ["enabled" ] != payload .enabled
2244+ or existing ["restart_time" ] != payload .restart_time
2245+ )
2246+ schedule = {
2247+ ** existing ,
2248+ "enabled" : payload .enabled ,
2249+ "restart_time" : payload .restart_time ,
2250+ }
2251+ if changed :
2252+ schedule ["last_result" ] = "not_run"
2253+ schedule ["last_message" ] = "새 예약이 저장되었습니다."
2254+ schedule ["last_run_date" ] = ""
2255+ schedule ["last_run_at" ] = ""
2256+ saved = write_restart_schedule (schedule )
2257+ state = "활성화" if saved ["enabled" ] else "비활성화"
2258+ return {
2259+ "status" : "ok" ,
2260+ "message" : f"게임 서버 예약 재시작이 { state } 되었습니다." ,
2261+ "schedule" : restart_schedule_response (saved ),
2262+ }
2263+
2264+
20282265@app .get ("/api/files" )
20292266def list_files (request : Request , path : str = "" ):
20302267 require_auth (request )
0 commit comments