procler 0.2.0__py3-none-any.whl

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.
Files changed (83) hide show
  1. procler/__init__.py +3 -0
  2. procler/__main__.py +6 -0
  3. procler/api/__init__.py +5 -0
  4. procler/api/app.py +261 -0
  5. procler/api/deps.py +21 -0
  6. procler/api/routes/__init__.py +5 -0
  7. procler/api/routes/config.py +290 -0
  8. procler/api/routes/groups.py +62 -0
  9. procler/api/routes/logs.py +43 -0
  10. procler/api/routes/processes.py +185 -0
  11. procler/api/routes/recipes.py +69 -0
  12. procler/api/routes/snippets.py +134 -0
  13. procler/api/routes/ws.py +459 -0
  14. procler/cli.py +1478 -0
  15. procler/config/__init__.py +65 -0
  16. procler/config/changelog.py +148 -0
  17. procler/config/loader.py +256 -0
  18. procler/config/schema.py +315 -0
  19. procler/core/__init__.py +54 -0
  20. procler/core/context_base.py +117 -0
  21. procler/core/context_docker.py +384 -0
  22. procler/core/context_local.py +287 -0
  23. procler/core/daemon_detector.py +325 -0
  24. procler/core/events.py +74 -0
  25. procler/core/groups.py +419 -0
  26. procler/core/health.py +280 -0
  27. procler/core/log_tailer.py +262 -0
  28. procler/core/process_manager.py +1277 -0
  29. procler/core/recipes.py +330 -0
  30. procler/core/snippets.py +231 -0
  31. procler/core/variable_substitution.py +65 -0
  32. procler/db.py +96 -0
  33. procler/logging.py +41 -0
  34. procler/models.py +130 -0
  35. procler/py.typed +0 -0
  36. procler/settings.py +29 -0
  37. procler/static/assets/AboutView-BwZnsfpW.js +4 -0
  38. procler/static/assets/AboutView-UHbxWXcS.css +1 -0
  39. procler/static/assets/Code-HTS-H1S6.js +74 -0
  40. procler/static/assets/ConfigView-CGJcmp9G.css +1 -0
  41. procler/static/assets/ConfigView-aVtbRDf8.js +1 -0
  42. procler/static/assets/DashboardView-C5jw9Nsd.css +1 -0
  43. procler/static/assets/DashboardView-Dab7Cu9v.js +1 -0
  44. procler/static/assets/DataTable-z39TOAa4.js +746 -0
  45. procler/static/assets/DescriptionsItem-B2E8YbqJ.js +74 -0
  46. procler/static/assets/Divider-Dk-6aD2Y.js +42 -0
  47. procler/static/assets/Empty-MuygEHZM.js +24 -0
  48. procler/static/assets/Grid-CZ9QVKAT.js +1 -0
  49. procler/static/assets/GroupsView-BALG7i1X.js +1 -0
  50. procler/static/assets/GroupsView-gXAI1CVC.css +1 -0
  51. procler/static/assets/Input-e0xaxoWE.js +259 -0
  52. procler/static/assets/PhArrowsClockwise.vue-DqDg31az.js +1 -0
  53. procler/static/assets/PhCheckCircle.vue-Fwj9sh9m.js +1 -0
  54. procler/static/assets/PhEye.vue-JcPHciC2.js +1 -0
  55. procler/static/assets/PhPlay.vue-CZm7Gy3u.js +1 -0
  56. procler/static/assets/PhPlus.vue-yTWqKlSh.js +1 -0
  57. procler/static/assets/PhStop.vue-DxsqwIki.js +1 -0
  58. procler/static/assets/PhTrash.vue-DcqQbN1_.js +125 -0
  59. procler/static/assets/PhXCircle.vue-BXWmrabV.js +1 -0
  60. procler/static/assets/ProcessDetailView-DDbtIWq9.css +1 -0
  61. procler/static/assets/ProcessDetailView-DPtdNV-q.js +1 -0
  62. procler/static/assets/ProcessesView-B3a6Umur.js +1 -0
  63. procler/static/assets/ProcessesView-goLmghbJ.css +1 -0
  64. procler/static/assets/RecipesView-D2VxdneD.js +166 -0
  65. procler/static/assets/RecipesView-DXnFDCK4.css +1 -0
  66. procler/static/assets/Select-BBR17AHq.js +317 -0
  67. procler/static/assets/SnippetsView-B3a9q3AI.css +1 -0
  68. procler/static/assets/SnippetsView-DBCB2yGq.js +1 -0
  69. procler/static/assets/Spin-BXTjvFUk.js +90 -0
  70. procler/static/assets/Tag-Bh_qV63A.js +71 -0
  71. procler/static/assets/changelog-KkTT4H9-.js +1 -0
  72. procler/static/assets/groups-Zu-_v8ey.js +1 -0
  73. procler/static/assets/index-BsN-YMXq.css +1 -0
  74. procler/static/assets/index-BzW1XhyH.js +1282 -0
  75. procler/static/assets/procler-DOrSB1Vj.js +1 -0
  76. procler/static/assets/recipes-1w5SseGb.js +1 -0
  77. procler/static/index.html +17 -0
  78. procler/static/procler.png +0 -0
  79. procler-0.2.0.dist-info/METADATA +545 -0
  80. procler-0.2.0.dist-info/RECORD +83 -0
  81. procler-0.2.0.dist-info/WHEEL +4 -0
  82. procler-0.2.0.dist-info/entry_points.txt +2 -0
  83. procler-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,43 @@
1
+ """Log retrieval API routes."""
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, Query
6
+ from pydantic import BaseModel
7
+
8
+ from ...core import ProcessManager
9
+ from ..deps import get_manager
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ class LogsResponse(BaseModel):
15
+ """Response for log queries."""
16
+
17
+ success: bool
18
+ data: dict[str, Any] | None = None
19
+ error: str | None = None
20
+ error_code: str | None = None
21
+
22
+
23
+ @router.get("/{name}")
24
+ async def get_logs(
25
+ name: str,
26
+ tail: int = Query(default=100, ge=1, le=10000, description="Number of lines to return"),
27
+ since: str | None = Query(default=None, description="Time filter (e.g., '5m', '1h', ISO timestamp)"),
28
+ manager: ProcessManager = Depends(get_manager),
29
+ ) -> LogsResponse:
30
+ """
31
+ Get logs for a process.
32
+
33
+ Args:
34
+ name: Process name
35
+ tail: Number of lines to return (default: 100, max: 10000)
36
+ since: Time filter (e.g., '5m', '1h', ISO timestamp)
37
+ """
38
+ result = await manager.logs(name, tail=tail, since=since)
39
+
40
+ if not result["success"]:
41
+ raise HTTPException(status_code=404, detail=result)
42
+
43
+ return LogsResponse(**result)
@@ -0,0 +1,185 @@
1
+ """Process management API routes."""
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from pydantic import BaseModel
7
+
8
+ from ...core import ProcessManager
9
+ from ..deps import get_manager
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ class ProcessDefineRequest(BaseModel):
15
+ """Request body for defining a process."""
16
+
17
+ name: str
18
+ command: str
19
+ context_type: str = "local"
20
+ container_name: str | None = None
21
+ cwd: str | None = None
22
+ display_name: str | None = None
23
+ tags: list[str] | None = None
24
+ force: bool = False
25
+
26
+
27
+ class ProcessResponse(BaseModel):
28
+ """Standard response wrapper."""
29
+
30
+ success: bool
31
+ data: dict[str, Any] | None = None
32
+ error: str | None = None
33
+ error_code: str | None = None
34
+ suggestion: str | None = None
35
+
36
+
37
+ @router.get("")
38
+ async def list_processes(manager: ProcessManager = Depends(get_manager)) -> ProcessResponse:
39
+ """List all process definitions."""
40
+ result = await manager.status()
41
+ return ProcessResponse(**result)
42
+
43
+
44
+ @router.get("/{name}")
45
+ async def get_process(
46
+ name: str,
47
+ manager: ProcessManager = Depends(get_manager),
48
+ ) -> ProcessResponse:
49
+ """Get a specific process by name."""
50
+ result = await manager.status(name)
51
+ if not result["success"]:
52
+ raise HTTPException(status_code=404, detail=result)
53
+ return ProcessResponse(**result)
54
+
55
+
56
+ @router.post("")
57
+ async def create_process(
58
+ request: ProcessDefineRequest,
59
+ manager: ProcessManager = Depends(get_manager),
60
+ ) -> ProcessResponse:
61
+ """Create or update a process definition."""
62
+ from datetime import datetime
63
+
64
+ from sqler.query import SQLerField as F
65
+
66
+ from ...db import init_database
67
+ from ...models import Process
68
+
69
+ init_database()
70
+
71
+ # Check if context is docker and container is missing
72
+ if request.context_type == "docker" and not request.container_name:
73
+ return ProcessResponse(
74
+ success=False,
75
+ error="Container name required for docker context",
76
+ error_code="missing_container",
77
+ suggestion="Provide container_name for docker context",
78
+ )
79
+
80
+ # Check if process already exists
81
+ existing = Process.query().filter(F("name") == request.name).all()
82
+ if existing:
83
+ if not request.force:
84
+ return ProcessResponse(
85
+ success=False,
86
+ error=f"Process '{request.name}' already exists",
87
+ error_code="process_exists",
88
+ suggestion=f"Use force=true to overwrite, or DELETE /api/processes/{request.name} first",
89
+ )
90
+ # Force mode: delete existing and continue
91
+ existing[0].delete()
92
+
93
+ process = Process(
94
+ name=request.name,
95
+ command=request.command,
96
+ context_type=request.context_type,
97
+ container_name=request.container_name,
98
+ cwd=request.cwd,
99
+ display_name=request.display_name,
100
+ tags=request.tags,
101
+ created_at=datetime.now().isoformat(),
102
+ updated_at=datetime.now().isoformat(),
103
+ )
104
+ process.save()
105
+
106
+ return ProcessResponse(
107
+ success=True,
108
+ data={
109
+ "action": "created",
110
+ "process": {
111
+ "id": process._id,
112
+ "name": process.name,
113
+ "command": process.command,
114
+ "context_type": process.context_type,
115
+ },
116
+ },
117
+ )
118
+
119
+
120
+ @router.delete("/{name}")
121
+ async def delete_process(
122
+ name: str,
123
+ manager: ProcessManager = Depends(get_manager),
124
+ ) -> ProcessResponse:
125
+ """Remove a process definition."""
126
+ from sqler.query import SQLerField as F
127
+
128
+ from ...db import init_database
129
+ from ...models import Process
130
+
131
+ init_database()
132
+
133
+ results = Process.query().filter(F("name") == name).all()
134
+ if not results:
135
+ raise HTTPException(
136
+ status_code=404,
137
+ detail={
138
+ "success": False,
139
+ "error": f"Process '{name}' not found",
140
+ "error_code": "process_not_found",
141
+ },
142
+ )
143
+
144
+ results[0].delete()
145
+ return ProcessResponse(
146
+ success=True,
147
+ data={"action": "removed", "name": name},
148
+ )
149
+
150
+
151
+ @router.post("/{name}/start")
152
+ async def start_process(
153
+ name: str,
154
+ manager: ProcessManager = Depends(get_manager),
155
+ ) -> ProcessResponse:
156
+ """Start a process."""
157
+ result = await manager.start(name)
158
+ if not result["success"]:
159
+ raise HTTPException(status_code=400, detail=result)
160
+ return ProcessResponse(**result)
161
+
162
+
163
+ @router.post("/{name}/stop")
164
+ async def stop_process(
165
+ name: str,
166
+ manager: ProcessManager = Depends(get_manager),
167
+ ) -> ProcessResponse:
168
+ """Stop a process."""
169
+ result = await manager.stop(name)
170
+ if not result["success"]:
171
+ raise HTTPException(status_code=400, detail=result)
172
+ return ProcessResponse(**result)
173
+
174
+
175
+ @router.post("/{name}/restart")
176
+ async def restart_process(
177
+ name: str,
178
+ clear_logs: bool = False,
179
+ manager: ProcessManager = Depends(get_manager),
180
+ ) -> ProcessResponse:
181
+ """Restart a process. Use ?clear_logs=true to delete old logs."""
182
+ result = await manager.restart(name, clear_logs=clear_logs)
183
+ if not result["success"]:
184
+ raise HTTPException(status_code=400, detail=result)
185
+ return ProcessResponse(**result)
@@ -0,0 +1,69 @@
1
+ """Recipe management API routes."""
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter
6
+ from pydantic import BaseModel
7
+
8
+ from ...core.recipes import get_recipe_executor
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ class RecipeResponse(BaseModel):
14
+ """Standard response wrapper."""
15
+
16
+ success: bool
17
+ data: dict[str, Any] | None = None
18
+ error: str | None = None
19
+ error_code: str | None = None
20
+ suggestion: str | None = None
21
+
22
+
23
+ class RunRecipeRequest(BaseModel):
24
+ """Request body for running a recipe."""
25
+
26
+ dry_run: bool = False
27
+ continue_on_error: bool | None = None
28
+
29
+
30
+ @router.get("")
31
+ async def list_recipes() -> RecipeResponse:
32
+ """List all defined recipes."""
33
+ executor = get_recipe_executor()
34
+ result = executor.list_recipes()
35
+ return RecipeResponse(**result)
36
+
37
+
38
+ @router.get("/{name}")
39
+ async def get_recipe(name: str) -> RecipeResponse:
40
+ """Get a specific recipe with full details."""
41
+ executor = get_recipe_executor()
42
+ result = executor.get_recipe(name)
43
+ return RecipeResponse(**result)
44
+
45
+
46
+ @router.post("/{name}/run")
47
+ async def run_recipe(name: str, request: RunRecipeRequest | None = None) -> RecipeResponse:
48
+ """
49
+ Execute a recipe.
50
+
51
+ Pass dry_run=true to preview what would happen without executing.
52
+ Pass continue_on_error to override the recipe's default error handling.
53
+ """
54
+ executor = get_recipe_executor()
55
+ req = request or RunRecipeRequest()
56
+ result = await executor.run_recipe(
57
+ name=name,
58
+ dry_run=req.dry_run,
59
+ continue_on_error=req.continue_on_error,
60
+ )
61
+ return RecipeResponse(**result)
62
+
63
+
64
+ @router.post("/{name}/dry-run")
65
+ async def dry_run_recipe(name: str) -> RecipeResponse:
66
+ """Preview what a recipe would do without executing."""
67
+ executor = get_recipe_executor()
68
+ result = await executor.run_recipe(name=name, dry_run=True)
69
+ return RecipeResponse(**result)
@@ -0,0 +1,134 @@
1
+ """Snippet management API routes."""
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, Query
6
+ from pydantic import BaseModel
7
+
8
+ from ...core import SnippetManager
9
+ from ..deps import get_snippets
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ class SnippetCreateRequest(BaseModel):
15
+ """Request body for creating a snippet."""
16
+
17
+ name: str
18
+ command: str
19
+ description: str | None = None
20
+ context_type: str = "local"
21
+ container_name: str | None = None
22
+ tags: list[str] | None = None
23
+
24
+
25
+ class SnippetResponse(BaseModel):
26
+ """Standard response wrapper for snippets."""
27
+
28
+ success: bool
29
+ data: dict[str, Any] | None = None
30
+ error: str | None = None
31
+ error_code: str | None = None
32
+ suggestion: str | None = None
33
+
34
+
35
+ @router.get("")
36
+ async def list_snippets(
37
+ tag: str | None = Query(default=None, description="Filter by tag"),
38
+ manager: SnippetManager = Depends(get_snippets),
39
+ ) -> SnippetResponse:
40
+ """List all snippets, optionally filtered by tag."""
41
+ result = manager.list_snippets(tag=tag)
42
+ return SnippetResponse(**result)
43
+
44
+
45
+ @router.post("")
46
+ async def create_snippet(
47
+ request: SnippetCreateRequest,
48
+ manager: SnippetManager = Depends(get_snippets),
49
+ ) -> SnippetResponse:
50
+ """Create a new snippet."""
51
+ result = manager.save_snippet(
52
+ name=request.name,
53
+ command=request.command,
54
+ description=request.description,
55
+ context_type=request.context_type,
56
+ container_name=request.container_name,
57
+ tags=request.tags,
58
+ )
59
+
60
+ if not result["success"]:
61
+ raise HTTPException(status_code=400, detail=result)
62
+
63
+ return SnippetResponse(**result)
64
+
65
+
66
+ @router.get("/{name}")
67
+ async def get_snippet(
68
+ name: str,
69
+ manager: SnippetManager = Depends(get_snippets),
70
+ ) -> SnippetResponse:
71
+ """Get a specific snippet by name."""
72
+ from sqler.query import SQLerField as F
73
+
74
+ from ...db import init_database
75
+ from ...models import Snippet
76
+
77
+ init_database()
78
+
79
+ results = Snippet.query().filter(F("name") == name).all()
80
+ if not results:
81
+ raise HTTPException(
82
+ status_code=404,
83
+ detail={
84
+ "success": False,
85
+ "error": f"Snippet '{name}' not found",
86
+ "error_code": "snippet_not_found",
87
+ },
88
+ )
89
+
90
+ snippet = results[0]
91
+ return SnippetResponse(
92
+ success=True,
93
+ data={
94
+ "snippet": {
95
+ "id": snippet._id,
96
+ "name": snippet.name,
97
+ "command": snippet.command,
98
+ "description": snippet.description,
99
+ "context_type": snippet.context_type,
100
+ "container_name": snippet.container_name,
101
+ "tags": snippet.tags or [],
102
+ "created_at": snippet.created_at,
103
+ }
104
+ },
105
+ )
106
+
107
+
108
+ @router.delete("/{name}")
109
+ async def delete_snippet(
110
+ name: str,
111
+ manager: SnippetManager = Depends(get_snippets),
112
+ ) -> SnippetResponse:
113
+ """Remove a snippet."""
114
+ result = manager.remove_snippet(name)
115
+
116
+ if not result["success"]:
117
+ raise HTTPException(status_code=404, detail=result)
118
+
119
+ return SnippetResponse(**result)
120
+
121
+
122
+ @router.post("/{name}/run")
123
+ async def run_snippet(
124
+ name: str,
125
+ manager: SnippetManager = Depends(get_snippets),
126
+ ) -> SnippetResponse:
127
+ """Run a snippet."""
128
+ result = await manager.run_snippet(name)
129
+
130
+ if not result["success"]:
131
+ status_code = 404 if result.get("error_code") == "snippet_not_found" else 400
132
+ raise HTTPException(status_code=status_code, detail=result)
133
+
134
+ return SnippetResponse(**result)