claude-controller 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ctl +867 -0
- package/bin/native-app.py +1 -1
- package/package.json +7 -5
- package/web/handler.py +190 -467
- package/web/handler_fs.py +152 -0
- package/web/handler_jobs.py +249 -0
- package/web/handler_sessions.py +132 -0
- package/web/jobs.py +9 -1
- package/web/pipeline.py +349 -0
- package/web/projects.py +192 -0
- package/web/static/api.js +54 -0
- package/web/static/app.js +17 -1937
- package/web/static/attachments.js +144 -0
- package/web/static/base.css +458 -0
- package/web/static/context.js +194 -0
- package/web/static/dirs.js +246 -0
- package/web/static/form.css +762 -0
- package/web/static/i18n.js +337 -0
- package/web/static/index.html +77 -11
- package/web/static/jobs.css +580 -0
- package/web/static/jobs.js +434 -0
- package/web/static/pipeline.css +31 -0
- package/web/static/pipelines.js +252 -0
- package/web/static/send.js +113 -0
- package/web/static/settings-style.css +260 -0
- package/web/static/settings.js +45 -0
- package/web/static/stream.js +311 -0
- package/web/static/utils.js +79 -0
- package/web/static/styles.css +0 -1922
package/web/pipeline.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pipeline Engine — on/off 자동화
|
|
3
|
+
|
|
4
|
+
상태: active / stopped
|
|
5
|
+
active + job_id → 작업 실행 중
|
|
6
|
+
active + !job_id → 대기 중 (next_run 카운트다운)
|
|
7
|
+
stopped → 꺼짐
|
|
8
|
+
|
|
9
|
+
저장: data/pipelines.json
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from config import DATA_DIR, LOGS_DIR
|
|
19
|
+
from jobs import send_to_fifo, get_job_result
|
|
20
|
+
from utils import parse_meta_file
|
|
21
|
+
|
|
22
|
+
PIPELINES_FILE = DATA_DIR / "pipelines.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ══════════════════════════════════════════════════════════════
|
|
26
|
+
# 유틸리티
|
|
27
|
+
# ══════════════════════════════════════════════════════════════
|
|
28
|
+
|
|
29
|
+
def _load_pipelines() -> list[dict]:
|
|
30
|
+
try:
|
|
31
|
+
if PIPELINES_FILE.exists():
|
|
32
|
+
return json.loads(PIPELINES_FILE.read_text("utf-8"))
|
|
33
|
+
except (json.JSONDecodeError, OSError):
|
|
34
|
+
pass
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _save_pipelines(pipelines: list[dict]):
|
|
39
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
PIPELINES_FILE.write_text(
|
|
41
|
+
json.dumps(pipelines, ensure_ascii=False, indent=2), "utf-8"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _generate_id() -> str:
|
|
46
|
+
return f"pipe-{int(time.time())}-{os.getpid() % 10000}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_interval(interval: str | None) -> int | None:
|
|
50
|
+
if not interval:
|
|
51
|
+
return None
|
|
52
|
+
m = re.match(r"^(\d+)\s*(s|m|h)$", interval.strip())
|
|
53
|
+
if not m:
|
|
54
|
+
return None
|
|
55
|
+
val, unit = int(m.group(1)), m.group(2)
|
|
56
|
+
return val * {"s": 1, "m": 60, "h": 3600}[unit]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _uuid_to_job_id(uuid: str) -> str | None:
|
|
60
|
+
if not LOGS_DIR.exists():
|
|
61
|
+
return None
|
|
62
|
+
for mf in sorted(LOGS_DIR.glob("job_*.meta"), reverse=True):
|
|
63
|
+
meta = parse_meta_file(mf)
|
|
64
|
+
if meta and meta.get("UUID") == uuid:
|
|
65
|
+
return meta.get("JOB_ID")
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_UUID_RESOLVE_TIMEOUT = 300
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_job(job_id: str, resolved_cache: str = None) -> tuple[str | None, str | None, str]:
|
|
73
|
+
"""job 결과 조회. 반환: (result_text, error, resolved_id)"""
|
|
74
|
+
resolved = resolved_cache or _uuid_to_job_id(job_id) or job_id
|
|
75
|
+
result, err = get_job_result(resolved)
|
|
76
|
+
if err:
|
|
77
|
+
if "-web-" in job_id:
|
|
78
|
+
try:
|
|
79
|
+
if time.time() - int(job_id.split("-")[0]) > _UUID_RESOLVE_TIMEOUT:
|
|
80
|
+
return None, "작업 유실", resolved
|
|
81
|
+
except (ValueError, IndexError):
|
|
82
|
+
pass
|
|
83
|
+
return None, "running", resolved
|
|
84
|
+
return None, err, resolved
|
|
85
|
+
if result and result.get("status") == "running":
|
|
86
|
+
return None, "running", resolved
|
|
87
|
+
if result:
|
|
88
|
+
return result.get("result", ""), None, resolved
|
|
89
|
+
return None, "결과 없음", resolved
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _update_pipeline(pipe_id: str, updater):
|
|
93
|
+
pipelines = _load_pipelines()
|
|
94
|
+
for p in pipelines:
|
|
95
|
+
if p["id"] == pipe_id:
|
|
96
|
+
updater(p)
|
|
97
|
+
p["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
98
|
+
_save_pipelines(pipelines)
|
|
99
|
+
return p, None
|
|
100
|
+
return None, "파이프라인을 찾을 수 없습니다"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _parse_timestamp(ts: str) -> float:
|
|
104
|
+
try:
|
|
105
|
+
return time.mktime(time.strptime(ts, "%Y-%m-%dT%H:%M:%S"))
|
|
106
|
+
except (ValueError, TypeError):
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _next_run_str(interval_sec: int) -> str:
|
|
111
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(time.time() + interval_sec))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ══════════════════════════════════════════════════════════════
|
|
115
|
+
# CRUD
|
|
116
|
+
# ══════════════════════════════════════════════════════════════
|
|
117
|
+
|
|
118
|
+
def list_pipelines() -> list[dict]:
|
|
119
|
+
return _load_pipelines()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_pipeline(pipe_id: str) -> tuple[dict | None, str | None]:
|
|
123
|
+
for p in _load_pipelines():
|
|
124
|
+
if p["id"] == pipe_id:
|
|
125
|
+
return p, None
|
|
126
|
+
return None, "파이프라인을 찾을 수 없습니다"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def create_pipeline(project_path: str, command: str, interval: str = "", name: str = "") -> tuple[dict | None, str | None]:
|
|
130
|
+
project_path = os.path.abspath(os.path.expanduser(project_path))
|
|
131
|
+
if not command.strip():
|
|
132
|
+
return None, "명령어(command)를 입력하세요"
|
|
133
|
+
if not name:
|
|
134
|
+
name = os.path.basename(project_path)
|
|
135
|
+
|
|
136
|
+
interval_sec = _parse_interval(interval) if interval else None
|
|
137
|
+
pipelines = _load_pipelines()
|
|
138
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
139
|
+
pipe = {
|
|
140
|
+
"id": _generate_id(),
|
|
141
|
+
"name": name,
|
|
142
|
+
"project_path": project_path,
|
|
143
|
+
"command": command,
|
|
144
|
+
"interval": interval or None,
|
|
145
|
+
"interval_sec": interval_sec,
|
|
146
|
+
"status": "active",
|
|
147
|
+
"job_id": None,
|
|
148
|
+
"next_run": None,
|
|
149
|
+
"last_run": None,
|
|
150
|
+
"last_result": None,
|
|
151
|
+
"last_error": None,
|
|
152
|
+
"run_count": 0,
|
|
153
|
+
"created_at": now,
|
|
154
|
+
"updated_at": now,
|
|
155
|
+
}
|
|
156
|
+
pipelines.append(pipe)
|
|
157
|
+
_save_pipelines(pipelines)
|
|
158
|
+
return pipe, None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def update_pipeline(pipe_id: str, command: str = None, interval: str = None, name: str = None) -> tuple[dict | None, str | None]:
|
|
162
|
+
def updater(p):
|
|
163
|
+
if command is not None:
|
|
164
|
+
p["command"] = command
|
|
165
|
+
if name is not None:
|
|
166
|
+
p["name"] = name
|
|
167
|
+
if interval is not None:
|
|
168
|
+
if interval == "":
|
|
169
|
+
p["interval"] = None
|
|
170
|
+
p["interval_sec"] = None
|
|
171
|
+
else:
|
|
172
|
+
p["interval"] = interval
|
|
173
|
+
p["interval_sec"] = _parse_interval(interval)
|
|
174
|
+
return _update_pipeline(pipe_id, updater)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def delete_pipeline(pipe_id: str) -> tuple[dict | None, str | None]:
|
|
178
|
+
pipelines = _load_pipelines()
|
|
179
|
+
for i, p in enumerate(pipelines):
|
|
180
|
+
if p["id"] == pipe_id:
|
|
181
|
+
removed = pipelines.pop(i)
|
|
182
|
+
_save_pipelines(pipelines)
|
|
183
|
+
return removed, None
|
|
184
|
+
return None, "파이프라인을 찾을 수 없습니다"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ══════════════════════════════════════════════════════════════
|
|
188
|
+
# 핵심: dispatch + tick
|
|
189
|
+
# ══════════════════════════════════════════════════════════════
|
|
190
|
+
|
|
191
|
+
def dispatch(pipe_id: str) -> tuple[dict | None, str | None]:
|
|
192
|
+
"""작업을 FIFO로 전송하고 next_run을 설정한다."""
|
|
193
|
+
pipe, err = get_pipeline(pipe_id)
|
|
194
|
+
if err:
|
|
195
|
+
return None, err
|
|
196
|
+
if pipe["status"] != "active":
|
|
197
|
+
return None, "파이프라인이 꺼져 있습니다"
|
|
198
|
+
|
|
199
|
+
result, send_err = send_to_fifo(pipe["command"], cwd=pipe["project_path"])
|
|
200
|
+
if send_err:
|
|
201
|
+
return None, f"FIFO 전송 실패: {send_err}"
|
|
202
|
+
|
|
203
|
+
job_id = result["job_id"]
|
|
204
|
+
nr = _next_run_str(pipe["interval_sec"]) if pipe.get("interval_sec") else None
|
|
205
|
+
|
|
206
|
+
def do_dispatch(p):
|
|
207
|
+
p["job_id"] = job_id
|
|
208
|
+
p["last_run"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
209
|
+
p["last_error"] = None
|
|
210
|
+
p["next_run"] = nr
|
|
211
|
+
_update_pipeline(pipe_id, do_dispatch)
|
|
212
|
+
|
|
213
|
+
return {"action": "dispatched", "job_id": job_id, "name": pipe["name"], "next_run": nr}, None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def tick(pipe_id: str) -> tuple[dict | None, str | None]:
|
|
217
|
+
"""active 파이프라인의 job 완료를 확인한다."""
|
|
218
|
+
pipe, err = get_pipeline(pipe_id)
|
|
219
|
+
if err:
|
|
220
|
+
return None, err
|
|
221
|
+
if pipe["status"] != "active":
|
|
222
|
+
return {"action": "off"}, None
|
|
223
|
+
|
|
224
|
+
job_id = pipe.get("job_id")
|
|
225
|
+
if not job_id:
|
|
226
|
+
# job 없음 → next_run 확인 후 dispatch
|
|
227
|
+
if pipe.get("next_run") and _parse_timestamp(pipe["next_run"]) > time.time():
|
|
228
|
+
remaining = int(_parse_timestamp(pipe["next_run"]) - time.time())
|
|
229
|
+
return {"action": "waiting", "remaining_sec": remaining}, None
|
|
230
|
+
return dispatch(pipe_id)
|
|
231
|
+
|
|
232
|
+
# job 실행 중 → 완료 확인
|
|
233
|
+
resolved_cache = pipe.get("resolved_job_id")
|
|
234
|
+
result_text, result_err, resolved = _resolve_job(job_id, resolved_cache)
|
|
235
|
+
|
|
236
|
+
# UUID 해석 결과 캐싱
|
|
237
|
+
if resolved != job_id and not resolved_cache:
|
|
238
|
+
def cache(p, _r=resolved):
|
|
239
|
+
p["resolved_job_id"] = _r
|
|
240
|
+
_update_pipeline(pipe_id, cache)
|
|
241
|
+
|
|
242
|
+
if result_err == "running":
|
|
243
|
+
return {"action": "running", "job_id": resolved}, None
|
|
244
|
+
|
|
245
|
+
if result_err:
|
|
246
|
+
def set_err(p, _e=result_err):
|
|
247
|
+
p["last_error"] = _e
|
|
248
|
+
p["job_id"] = None
|
|
249
|
+
p.pop("resolved_job_id", None)
|
|
250
|
+
_update_pipeline(pipe_id, set_err)
|
|
251
|
+
return {"action": "error", "error": result_err}, None
|
|
252
|
+
|
|
253
|
+
# 완료
|
|
254
|
+
summary = (result_text or "")[:200]
|
|
255
|
+
def complete(p, _s=summary):
|
|
256
|
+
p["last_result"] = _s
|
|
257
|
+
p["run_count"] = p.get("run_count", 0) + 1
|
|
258
|
+
p["job_id"] = None
|
|
259
|
+
p.pop("resolved_job_id", None)
|
|
260
|
+
_update_pipeline(pipe_id, complete)
|
|
261
|
+
|
|
262
|
+
return {"action": "completed", "run_count": pipe.get("run_count", 0) + 1}, None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ══════════════════════════════════════════════════════════════
|
|
266
|
+
# 액션 함수
|
|
267
|
+
# ══════════════════════════════════════════════════════════════
|
|
268
|
+
|
|
269
|
+
def run_next(pipe_id: str) -> tuple[dict | None, str | None]:
|
|
270
|
+
"""ON으로 켜고 즉시 dispatch."""
|
|
271
|
+
def activate(p):
|
|
272
|
+
p["status"] = "active"
|
|
273
|
+
p["job_id"] = None
|
|
274
|
+
p["next_run"] = None
|
|
275
|
+
p["last_error"] = None
|
|
276
|
+
_update_pipeline(pipe_id, activate)
|
|
277
|
+
return dispatch(pipe_id)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def force_run(pipe_id: str) -> tuple[dict | None, str | None]:
|
|
281
|
+
return run_next(pipe_id)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def stop_pipeline(pipe_id: str) -> tuple[dict | None, str | None]:
|
|
285
|
+
"""OFF로 끈다."""
|
|
286
|
+
def stop(p):
|
|
287
|
+
p["status"] = "stopped"
|
|
288
|
+
p["job_id"] = None
|
|
289
|
+
p["next_run"] = None
|
|
290
|
+
p["last_error"] = None
|
|
291
|
+
return _update_pipeline(pipe_id, stop)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def reset_phase(pipe_id: str, phase: str = None) -> tuple[dict | None, str | None]:
|
|
295
|
+
return run_next(pipe_id)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def get_pipeline_status(pipe_id: str) -> tuple[dict | None, str | None]:
|
|
299
|
+
pipe, err = get_pipeline(pipe_id)
|
|
300
|
+
if err:
|
|
301
|
+
return None, err
|
|
302
|
+
|
|
303
|
+
job_status = None
|
|
304
|
+
if pipe.get("job_id"):
|
|
305
|
+
resolved = pipe.get("resolved_job_id") or _uuid_to_job_id(pipe["job_id"]) or pipe["job_id"]
|
|
306
|
+
result, _ = get_job_result(resolved)
|
|
307
|
+
if result:
|
|
308
|
+
job_status = {
|
|
309
|
+
"job_id": resolved,
|
|
310
|
+
"status": result.get("status"),
|
|
311
|
+
"cost_usd": result.get("cost_usd"),
|
|
312
|
+
"duration_ms": result.get("duration_ms"),
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
remaining_sec = None
|
|
316
|
+
if pipe.get("next_run"):
|
|
317
|
+
remaining_sec = max(0, int(_parse_timestamp(pipe["next_run"]) - time.time()))
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
"id": pipe["id"],
|
|
321
|
+
"name": pipe["name"],
|
|
322
|
+
"project_path": pipe["project_path"],
|
|
323
|
+
"command": pipe["command"],
|
|
324
|
+
"interval": pipe.get("interval"),
|
|
325
|
+
"status": pipe["status"],
|
|
326
|
+
"job_id": pipe.get("job_id"),
|
|
327
|
+
"job_status": job_status,
|
|
328
|
+
"next_run": pipe.get("next_run"),
|
|
329
|
+
"remaining_sec": remaining_sec,
|
|
330
|
+
"last_run": pipe.get("last_run"),
|
|
331
|
+
"last_result": pipe.get("last_result"),
|
|
332
|
+
"last_error": pipe.get("last_error"),
|
|
333
|
+
"run_count": pipe.get("run_count", 0),
|
|
334
|
+
"created_at": pipe["created_at"],
|
|
335
|
+
"updated_at": pipe["updated_at"],
|
|
336
|
+
}, None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ══════════════════════════════════════════════════════════════
|
|
340
|
+
# Tick All
|
|
341
|
+
# ══════════════════════════════════════════════════════════════
|
|
342
|
+
|
|
343
|
+
def tick_all() -> list[dict]:
|
|
344
|
+
results = []
|
|
345
|
+
for p in _load_pipelines():
|
|
346
|
+
if p["status"] == "active":
|
|
347
|
+
result, err = tick(p["id"])
|
|
348
|
+
results.append({"pipeline_id": p["id"], "name": p["name"], "result": result, "error": err})
|
|
349
|
+
return results
|
package/web/projects.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Controller Service — 프로젝트 관리
|
|
3
|
+
|
|
4
|
+
프로젝트는 자동화 대상 저장소/디렉토리를 등록·관리하는 단위이다.
|
|
5
|
+
기존 프로젝트를 등록하거나, 신규 프로젝트를 생성(디렉토리 + git init)할 수 있다.
|
|
6
|
+
저장: data/projects.json
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from config import DATA_DIR
|
|
16
|
+
|
|
17
|
+
PROJECTS_FILE = DATA_DIR / "projects.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_projects() -> list[dict]:
|
|
21
|
+
"""프로젝트 목록을 파일에서 읽는다."""
|
|
22
|
+
try:
|
|
23
|
+
if PROJECTS_FILE.exists():
|
|
24
|
+
return json.loads(PROJECTS_FILE.read_text("utf-8"))
|
|
25
|
+
except (json.JSONDecodeError, OSError):
|
|
26
|
+
pass
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _save_projects(projects: list[dict]):
|
|
31
|
+
"""프로젝트 목록을 파일에 저장한다."""
|
|
32
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
PROJECTS_FILE.write_text(
|
|
34
|
+
json.dumps(projects, ensure_ascii=False, indent=2), "utf-8"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _generate_id() -> str:
|
|
39
|
+
return f"{int(time.time())}-{os.getpid()}-{id(time) % 10000}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _detect_git_info(path: str) -> dict:
|
|
43
|
+
"""디렉토리의 git 정보를 감지한다."""
|
|
44
|
+
info = {"is_git": False, "branch": "", "remote": ""}
|
|
45
|
+
try:
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
48
|
+
cwd=path, capture_output=True, text=True, timeout=5,
|
|
49
|
+
)
|
|
50
|
+
if result.returncode != 0:
|
|
51
|
+
return info
|
|
52
|
+
info["is_git"] = True
|
|
53
|
+
|
|
54
|
+
result = subprocess.run(
|
|
55
|
+
["git", "branch", "--show-current"],
|
|
56
|
+
cwd=path, capture_output=True, text=True, timeout=5,
|
|
57
|
+
)
|
|
58
|
+
info["branch"] = result.stdout.strip()
|
|
59
|
+
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
["git", "remote", "get-url", "origin"],
|
|
62
|
+
cwd=path, capture_output=True, text=True, timeout=5,
|
|
63
|
+
)
|
|
64
|
+
info["remote"] = result.stdout.strip()
|
|
65
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
66
|
+
pass
|
|
67
|
+
return info
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ══════════════════════════════════════════════════════════════
|
|
71
|
+
# CRUD
|
|
72
|
+
# ══════════════════════════════════════════════════════════════
|
|
73
|
+
|
|
74
|
+
def list_projects() -> list[dict]:
|
|
75
|
+
"""등록된 프로젝트 목록을 반환한다."""
|
|
76
|
+
projects = _load_projects()
|
|
77
|
+
# 경로 유효성 보강
|
|
78
|
+
for p in projects:
|
|
79
|
+
p["exists"] = os.path.isdir(p.get("path", ""))
|
|
80
|
+
return projects
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_project(project_id: str) -> tuple[dict | None, str | None]:
|
|
84
|
+
"""ID로 프로젝트를 조회한다."""
|
|
85
|
+
projects = _load_projects()
|
|
86
|
+
for p in projects:
|
|
87
|
+
if p["id"] == project_id:
|
|
88
|
+
p["exists"] = os.path.isdir(p.get("path", ""))
|
|
89
|
+
git_info = _detect_git_info(p["path"]) if p["exists"] else {}
|
|
90
|
+
p.update(git_info)
|
|
91
|
+
return p, None
|
|
92
|
+
return None, "프로젝트를 찾을 수 없습니다"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def add_project(path: str, name: str = "", description: str = "") -> tuple[dict | None, str | None]:
|
|
96
|
+
"""기존 디렉토리를 프로젝트로 등록한다."""
|
|
97
|
+
path = os.path.abspath(os.path.expanduser(path))
|
|
98
|
+
if not os.path.isdir(path):
|
|
99
|
+
return None, f"디렉토리가 존재하지 않습니다: {path}"
|
|
100
|
+
|
|
101
|
+
projects = _load_projects()
|
|
102
|
+
|
|
103
|
+
# 중복 체크
|
|
104
|
+
for p in projects:
|
|
105
|
+
if os.path.normpath(p["path"]) == os.path.normpath(path):
|
|
106
|
+
return None, f"이미 등록된 프로젝트입니다: {p['name']} ({p['id']})"
|
|
107
|
+
|
|
108
|
+
if not name:
|
|
109
|
+
name = os.path.basename(path)
|
|
110
|
+
|
|
111
|
+
git_info = _detect_git_info(path)
|
|
112
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
113
|
+
|
|
114
|
+
project = {
|
|
115
|
+
"id": _generate_id(),
|
|
116
|
+
"name": name,
|
|
117
|
+
"path": path,
|
|
118
|
+
"description": description,
|
|
119
|
+
"is_git": git_info["is_git"],
|
|
120
|
+
"branch": git_info["branch"],
|
|
121
|
+
"remote": git_info["remote"],
|
|
122
|
+
"created_at": now,
|
|
123
|
+
"last_used_at": now,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
projects.append(project)
|
|
127
|
+
_save_projects(projects)
|
|
128
|
+
return project, None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def create_project(path: str, name: str = "", description: str = "",
|
|
132
|
+
init_git: bool = True) -> tuple[dict | None, str | None]:
|
|
133
|
+
"""신규 프로젝트를 생성한다 (디렉토리 생성 + git init + 등록)."""
|
|
134
|
+
path = os.path.abspath(os.path.expanduser(path))
|
|
135
|
+
|
|
136
|
+
if os.path.exists(path):
|
|
137
|
+
return None, f"이미 존재하는 경로입니다: {path}"
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
os.makedirs(path)
|
|
141
|
+
except OSError as e:
|
|
142
|
+
return None, f"디렉토리 생성 실패: {e}"
|
|
143
|
+
|
|
144
|
+
if init_git:
|
|
145
|
+
try:
|
|
146
|
+
subprocess.run(
|
|
147
|
+
["git", "init"],
|
|
148
|
+
cwd=path, capture_output=True, text=True, timeout=10, check=True,
|
|
149
|
+
)
|
|
150
|
+
except (subprocess.CalledProcessError, OSError) as e:
|
|
151
|
+
return None, f"git init 실패: {e}"
|
|
152
|
+
|
|
153
|
+
if not name:
|
|
154
|
+
name = os.path.basename(path)
|
|
155
|
+
|
|
156
|
+
return add_project(path, name=name, description=description)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def remove_project(project_id: str) -> tuple[dict | None, str | None]:
|
|
160
|
+
"""프로젝트 등록을 해제한다 (디렉토리는 삭제하지 않음)."""
|
|
161
|
+
projects = _load_projects()
|
|
162
|
+
for i, p in enumerate(projects):
|
|
163
|
+
if p["id"] == project_id:
|
|
164
|
+
removed = projects.pop(i)
|
|
165
|
+
_save_projects(projects)
|
|
166
|
+
return removed, None
|
|
167
|
+
return None, "프로젝트를 찾을 수 없습니다"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def update_project(project_id: str, **kwargs) -> tuple[dict | None, str | None]:
|
|
171
|
+
"""프로젝트 정보를 업데이트한다."""
|
|
172
|
+
projects = _load_projects()
|
|
173
|
+
allowed = {"name", "description"}
|
|
174
|
+
for p in projects:
|
|
175
|
+
if p["id"] == project_id:
|
|
176
|
+
for k, v in kwargs.items():
|
|
177
|
+
if k in allowed and v is not None:
|
|
178
|
+
p[k] = v
|
|
179
|
+
p["last_used_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
180
|
+
_save_projects(projects)
|
|
181
|
+
return p, None
|
|
182
|
+
return None, "프로젝트를 찾을 수 없습니다"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def touch_project(project_id: str):
|
|
186
|
+
"""last_used_at을 갱신한다."""
|
|
187
|
+
projects = _load_projects()
|
|
188
|
+
for p in projects:
|
|
189
|
+
if p["id"] == project_id:
|
|
190
|
+
p["last_used_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
191
|
+
_save_projects(projects)
|
|
192
|
+
return
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════
|
|
2
|
+
API & Service — 백엔드 연결, API 호출, 서비스 상태
|
|
3
|
+
═══════════════════════════════════════════════ */
|
|
4
|
+
|
|
5
|
+
const LOCAL_BACKEND = 'http://localhost:8420';
|
|
6
|
+
let API = '';
|
|
7
|
+
let AUTH_TOKEN = '';
|
|
8
|
+
let _backendConnected = false;
|
|
9
|
+
let serviceRunning = null;
|
|
10
|
+
|
|
11
|
+
async function apiFetch(path, options = {}) {
|
|
12
|
+
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
|
13
|
+
if (AUTH_TOKEN) {
|
|
14
|
+
headers['Authorization'] = `Bearer ${AUTH_TOKEN}`;
|
|
15
|
+
}
|
|
16
|
+
const resp = await fetch(`${API}${path}`, { ...options, headers });
|
|
17
|
+
if (!resp.ok) {
|
|
18
|
+
const text = await resp.text().catch(() => '');
|
|
19
|
+
throw new Error(text || `HTTP ${resp.status}`);
|
|
20
|
+
}
|
|
21
|
+
const ct = resp.headers.get('content-type') || '';
|
|
22
|
+
if (ct.includes('application/json')) return resp.json();
|
|
23
|
+
return resp.text();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function checkStatus() {
|
|
27
|
+
try {
|
|
28
|
+
const data = await apiFetch('/api/status');
|
|
29
|
+
const running = data.running !== undefined ? data.running : (data.status === 'running');
|
|
30
|
+
serviceRunning = running;
|
|
31
|
+
} catch {
|
|
32
|
+
serviceRunning = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function serviceAction(action) {
|
|
37
|
+
const btn = document.getElementById(`btn${action.charAt(0).toUpperCase() + action.slice(1)}`);
|
|
38
|
+
if (btn) btn.disabled = true;
|
|
39
|
+
try {
|
|
40
|
+
if (action === 'restart') {
|
|
41
|
+
await apiFetch('/api/service/stop', { method: 'POST' });
|
|
42
|
+
await new Promise(r => setTimeout(r, 500));
|
|
43
|
+
await apiFetch('/api/service/start', { method: 'POST' });
|
|
44
|
+
} else {
|
|
45
|
+
await apiFetch(`/api/service/${action}`, { method: 'POST' });
|
|
46
|
+
}
|
|
47
|
+
showToast(t(action === 'start' ? 'msg_service_start' : action === 'stop' ? 'msg_service_stop' : 'msg_service_restart'));
|
|
48
|
+
setTimeout(checkStatus, 1000);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
showToast(`${t('msg_service_failed')}: ${err.message}`, 'error');
|
|
51
|
+
} finally {
|
|
52
|
+
if (btn) btn.disabled = false;
|
|
53
|
+
}
|
|
54
|
+
}
|