pulice 0.1.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.
- pulice/__init__.py +19 -0
- pulice/admin/__init__.py +13 -0
- pulice/admin/__main__.py +8 -0
- pulice/admin/app.py +90 -0
- pulice/admin/data.py +112 -0
- pulice/admin/screens/__init__.py +15 -0
- pulice/admin/screens/dashboard.py +61 -0
- pulice/admin/screens/stacks.py +86 -0
- pulice/admin/screens/system.py +67 -0
- pulice/admin/screens/tasks.py +92 -0
- pulice/admin/screens/tenants.py +78 -0
- pulice/admin/serve.py +45 -0
- pulice/admin/styles/app.tcss +39 -0
- pulice/admin/widgets/__init__.py +6 -0
- pulice/admin/widgets/confirm.py +48 -0
- pulice/admin/widgets/stat_card.py +49 -0
- pulice/api/__init__.py +25 -0
- pulice/api/models.py +82 -0
- pulice/api/routes_stacks.py +95 -0
- pulice/api/routes_tasks.py +48 -0
- pulice/api/routes_tenants.py +65 -0
- pulice/cli/__init__.py +15 -0
- pulice/cli/app.py +82 -0
- pulice/cli/registry.py +558 -0
- pulice/core/__init__.py +50 -0
- pulice/core/base.py +49 -0
- pulice/core/celery_backend.py +89 -0
- pulice/core/controllers.py +130 -0
- pulice/core/managed.py +172 -0
- pulice/core/protocol.py +49 -0
- pulice/core/stack.py +743 -0
- pulice/core/task_definitions.py +211 -0
- pulice/core/tasks.py +222 -0
- pulice/py.typed +0 -0
- pulice/worker.py +12 -0
- pulice-0.1.0.dist-info/METADATA +128 -0
- pulice-0.1.0.dist-info/RECORD +38 -0
- pulice-0.1.0.dist-info/WHEEL +4 -0
pulice/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
__version__ = '0.1.0'
|
|
2
|
+
|
|
3
|
+
from pulice.cli.app import PuliceCLI
|
|
4
|
+
from pulice.core.base import ComponentArgs, ManagedComponent
|
|
5
|
+
from pulice.core.controllers import ComponentController, WorkspaceController
|
|
6
|
+
from pulice.core.protocol import PuliceApp
|
|
7
|
+
from pulice.core.tasks import TaskBackend, TaskResult, TaskStatus
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'ComponentArgs',
|
|
11
|
+
'ComponentController',
|
|
12
|
+
'ManagedComponent',
|
|
13
|
+
'PuliceApp',
|
|
14
|
+
'PuliceCLI',
|
|
15
|
+
'TaskBackend',
|
|
16
|
+
'TaskResult',
|
|
17
|
+
'TaskStatus',
|
|
18
|
+
'WorkspaceController',
|
|
19
|
+
]
|
pulice/admin/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Pulice Admin TUI — terminal and browser dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_admin_app(
|
|
7
|
+
state_dir: str | None = None,
|
|
8
|
+
refresh_interval: int = 5,
|
|
9
|
+
) -> 'PuliceAdmin': # noqa: F821 # pyrefly: ignore
|
|
10
|
+
"""Create and return the admin TUI application."""
|
|
11
|
+
from pulice.admin.app import PuliceAdmin
|
|
12
|
+
|
|
13
|
+
return PuliceAdmin(state_dir=state_dir, refresh_interval=refresh_interval)
|
pulice/admin/__main__.py
ADDED
pulice/admin/app.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""PuliceAdmin — main Textual application for the admin TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.widgets import Footer, Header, TabbedContent, TabPane
|
|
9
|
+
from pulice.admin.data import AdminDataSource
|
|
10
|
+
from pulice.admin.screens.dashboard import DashboardScreen
|
|
11
|
+
from pulice.admin.screens.stacks import StacksScreen
|
|
12
|
+
from pulice.admin.screens.system import SystemScreen
|
|
13
|
+
from pulice.admin.screens.tasks import TasksScreen
|
|
14
|
+
from pulice.admin.screens.tenants import TenantsScreen
|
|
15
|
+
|
|
16
|
+
CSS_PATH = Path(__file__).parent / 'styles' / 'app.tcss'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PuliceAdmin(App):
|
|
20
|
+
"""Pulice administrative dashboard TUI."""
|
|
21
|
+
|
|
22
|
+
TITLE = 'Pulice Admin'
|
|
23
|
+
CSS_PATH = CSS_PATH
|
|
24
|
+
BINDINGS = [
|
|
25
|
+
Binding('1', "switch_tab('dashboard')", 'Dashboard', show=False),
|
|
26
|
+
Binding('2', "switch_tab('tenants')", 'Tenants', show=False),
|
|
27
|
+
Binding('3', "switch_tab('stacks')", 'Stacks', show=False),
|
|
28
|
+
Binding('4', "switch_tab('tasks')", 'Tasks', show=False),
|
|
29
|
+
Binding('5', "switch_tab('system')", 'System', show=False),
|
|
30
|
+
Binding('r', 'force_refresh', 'Refresh'),
|
|
31
|
+
Binding('q', 'quit', 'Quit'),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
state_dir: str | None = None,
|
|
37
|
+
refresh_interval: int = 5,
|
|
38
|
+
**kwargs,
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(**kwargs)
|
|
41
|
+
self._state_dir = state_dir
|
|
42
|
+
self._refresh_interval = refresh_interval
|
|
43
|
+
self._data = AdminDataSource(state_dir=state_dir)
|
|
44
|
+
|
|
45
|
+
def compose(self) -> ComposeResult:
|
|
46
|
+
yield Header()
|
|
47
|
+
with Vertical(id='main'):
|
|
48
|
+
with TabbedContent(id='tabs'):
|
|
49
|
+
with TabPane('Dashboard', id='dashboard'):
|
|
50
|
+
yield DashboardScreen(self._data)
|
|
51
|
+
with TabPane('Tenants', id='tenants'):
|
|
52
|
+
yield TenantsScreen(self._data)
|
|
53
|
+
with TabPane('Stacks', id='stacks'):
|
|
54
|
+
yield StacksScreen(self._data)
|
|
55
|
+
with TabPane('Tasks', id='tasks'):
|
|
56
|
+
yield TasksScreen(self._data)
|
|
57
|
+
with TabPane('System', id='system'):
|
|
58
|
+
yield SystemScreen(self._data)
|
|
59
|
+
yield Footer()
|
|
60
|
+
|
|
61
|
+
def on_mount(self) -> None:
|
|
62
|
+
self._do_refresh()
|
|
63
|
+
if self._refresh_interval > 0:
|
|
64
|
+
self.set_interval(self._refresh_interval, self._do_refresh)
|
|
65
|
+
|
|
66
|
+
def _do_refresh(self) -> None:
|
|
67
|
+
self.run_worker(self._refresh_worker, exclusive=True)
|
|
68
|
+
|
|
69
|
+
async def _refresh_worker(self) -> None:
|
|
70
|
+
for screen_cls in (
|
|
71
|
+
DashboardScreen,
|
|
72
|
+
TenantsScreen,
|
|
73
|
+
StacksScreen,
|
|
74
|
+
TasksScreen,
|
|
75
|
+
SystemScreen,
|
|
76
|
+
):
|
|
77
|
+
try:
|
|
78
|
+
widget = self.query_one(screen_cls) # pyrefly: ignore
|
|
79
|
+
widget.refresh_data()
|
|
80
|
+
except Exception: # noseq
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def action_switch_tab(self, tab_id: str) -> None:
|
|
84
|
+
tabs = self.query_one('#tabs', TabbedContent)
|
|
85
|
+
tabs.active = tab_id # pyrefly: ignore
|
|
86
|
+
self._do_refresh()
|
|
87
|
+
|
|
88
|
+
def action_force_refresh(self) -> None:
|
|
89
|
+
self._do_refresh()
|
|
90
|
+
self.notify('Refreshed')
|
pulice/admin/data.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Read-only data provider for the admin TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import pulice
|
|
9
|
+
from pulice.core.stack import (
|
|
10
|
+
LocalStackReferenceStore,
|
|
11
|
+
SqliteBackendStorage,
|
|
12
|
+
StackReference,
|
|
13
|
+
Tenant,
|
|
14
|
+
)
|
|
15
|
+
from pulice.core.tasks import TaskBackend, TaskResult
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AdminDataSource:
|
|
20
|
+
"""Read-only data provider for the admin TUI.
|
|
21
|
+
|
|
22
|
+
Wraps existing storage APIs to provide data for all admin screens.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, state_dir: str | None = None) -> None:
|
|
26
|
+
self._state_dir = state_dir or os.getenv('PULICE_STATE_DIR')
|
|
27
|
+
self._storage = SqliteBackendStorage(root_dir=self._state_dir)
|
|
28
|
+
self._references = LocalStackReferenceStore(root_dir=self._state_dir)
|
|
29
|
+
self._task_backend: TaskBackend | None = None
|
|
30
|
+
|
|
31
|
+
def _get_task_backend(self) -> TaskBackend:
|
|
32
|
+
if self._task_backend is None:
|
|
33
|
+
from pulice.core.tasks import get_task_backend
|
|
34
|
+
|
|
35
|
+
self._task_backend = get_task_backend(state_dir=self._state_dir)
|
|
36
|
+
return self._task_backend
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def state_dir(self) -> str:
|
|
40
|
+
return str(self._storage._root)
|
|
41
|
+
|
|
42
|
+
def get_tenants(self) -> list[Tenant]:
|
|
43
|
+
return self._storage.list_tenants()
|
|
44
|
+
|
|
45
|
+
def get_stacks(self, tenant_id: str | None = None) -> list[dict[str, Any]]:
|
|
46
|
+
return self._storage.list_stacks(tenant_id=tenant_id)
|
|
47
|
+
|
|
48
|
+
def get_stack_references(self, tenant_id: str | None = None) -> list[StackReference]:
|
|
49
|
+
return self._references.list(tenant_id=tenant_id)
|
|
50
|
+
|
|
51
|
+
def get_locks(self) -> list[dict[str, Any]]:
|
|
52
|
+
with self._storage._connect() as conn:
|
|
53
|
+
rows = conn.execute(
|
|
54
|
+
'SELECT stack_name, holder, operation, locked_at FROM stack_locks'
|
|
55
|
+
).fetchall()
|
|
56
|
+
now = datetime.now(timezone.utc)
|
|
57
|
+
results = []
|
|
58
|
+
for r in rows:
|
|
59
|
+
locked_at = datetime.fromisoformat(r[3])
|
|
60
|
+
age_seconds = (now - locked_at).total_seconds()
|
|
61
|
+
results.append(
|
|
62
|
+
{
|
|
63
|
+
'stack_name': r[0],
|
|
64
|
+
'holder': r[1],
|
|
65
|
+
'operation': r[2],
|
|
66
|
+
'locked_at': r[3],
|
|
67
|
+
'age_seconds': age_seconds,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
return results
|
|
71
|
+
|
|
72
|
+
def get_task_status(self, task_id: str) -> TaskResult:
|
|
73
|
+
return self._get_task_backend().get_status(task_id)
|
|
74
|
+
|
|
75
|
+
def get_system_info(self) -> dict[str, Any]:
|
|
76
|
+
state_path = Path(self.state_dir)
|
|
77
|
+
db_path = state_path / 'pulice_stacks.sqlite3'
|
|
78
|
+
|
|
79
|
+
total_size = sum(f.stat().st_size for f in state_path.rglob('*') if f.is_file())
|
|
80
|
+
db_size = db_path.stat().st_size if db_path.exists() else 0
|
|
81
|
+
|
|
82
|
+
tenants = self.get_tenants()
|
|
83
|
+
stacks = self.get_stacks()
|
|
84
|
+
locks = self.get_locks()
|
|
85
|
+
|
|
86
|
+
backend_type = os.getenv('PULICE_TASK_BACKEND', 'huey')
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
'version': pulice.__version__,
|
|
90
|
+
'python': sys.version,
|
|
91
|
+
'state_dir': self.state_dir,
|
|
92
|
+
'state_dir_size': total_size,
|
|
93
|
+
'db_size': db_size,
|
|
94
|
+
'task_backend': backend_type,
|
|
95
|
+
'tenant_count': len(tenants),
|
|
96
|
+
'stack_count': len(stacks),
|
|
97
|
+
'lock_count': len(locks),
|
|
98
|
+
'locks': locks,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def delete_tenant(self, name: str) -> None:
|
|
102
|
+
self._storage.delete_tenant(name)
|
|
103
|
+
|
|
104
|
+
def release_lock(self, stack_name: str) -> None:
|
|
105
|
+
with self._storage._connect() as conn:
|
|
106
|
+
conn.execute('DELETE FROM stack_locks WHERE stack_name = ?', (stack_name,))
|
|
107
|
+
|
|
108
|
+
def cancel_task(self, task_id: str) -> bool:
|
|
109
|
+
return self._get_task_backend().cancel(task_id)
|
|
110
|
+
|
|
111
|
+
def retry_task(self, task_id: str) -> str:
|
|
112
|
+
return self._get_task_backend().retry(task_id)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Admin TUI screens."""
|
|
2
|
+
|
|
3
|
+
from pulice.admin.screens.dashboard import DashboardScreen
|
|
4
|
+
from pulice.admin.screens.stacks import StacksScreen
|
|
5
|
+
from pulice.admin.screens.system import SystemScreen
|
|
6
|
+
from pulice.admin.screens.tasks import TasksScreen
|
|
7
|
+
from pulice.admin.screens.tenants import TenantsScreen
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'DashboardScreen',
|
|
11
|
+
'TenantsScreen',
|
|
12
|
+
'StacksScreen',
|
|
13
|
+
'TasksScreen',
|
|
14
|
+
'SystemScreen',
|
|
15
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Dashboard screen — summary view with key metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.containers import Horizontal, Vertical
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
from pulice.admin.widgets.stat_card import StatCard
|
|
8
|
+
|
|
9
|
+
if __name__ != '__main__':
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pulice.admin.data import AdminDataSource
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DashboardScreen(Static):
|
|
17
|
+
"""Summary dashboard showing key metrics at a glance."""
|
|
18
|
+
|
|
19
|
+
DEFAULT_CSS = """
|
|
20
|
+
DashboardScreen {
|
|
21
|
+
height: 1fr;
|
|
22
|
+
padding: 1 2;
|
|
23
|
+
}
|
|
24
|
+
DashboardScreen .metrics-row {
|
|
25
|
+
height: 7;
|
|
26
|
+
margin-bottom: 1;
|
|
27
|
+
}
|
|
28
|
+
DashboardScreen .info-section {
|
|
29
|
+
height: auto;
|
|
30
|
+
padding: 1;
|
|
31
|
+
}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, data_source: 'AdminDataSource') -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._data = data_source
|
|
37
|
+
|
|
38
|
+
def compose(self) -> ComposeResult:
|
|
39
|
+
with Horizontal(classes='metrics-row'):
|
|
40
|
+
yield StatCard('Tenants', '0', id='stat-tenants')
|
|
41
|
+
yield StatCard('Stacks', '0', id='stat-stacks')
|
|
42
|
+
yield StatCard('Active Locks', '0', id='stat-locks')
|
|
43
|
+
with Horizontal(classes='metrics-row'):
|
|
44
|
+
yield StatCard('Pending Tasks', '—', id='stat-pending')
|
|
45
|
+
yield StatCard('Running Tasks', '—', id='stat-running')
|
|
46
|
+
yield StatCard('Failed Tasks', '—', id='stat-failed')
|
|
47
|
+
with Vertical(classes='info-section'):
|
|
48
|
+
yield Static(id='system-info')
|
|
49
|
+
|
|
50
|
+
def refresh_data(self) -> None:
|
|
51
|
+
info = self._data.get_system_info()
|
|
52
|
+
self.query_one('#stat-tenants', StatCard).update_value(str(info['tenant_count']))
|
|
53
|
+
self.query_one('#stat-stacks', StatCard).update_value(str(info['stack_count']))
|
|
54
|
+
self.query_one('#stat-locks', StatCard).update_value(str(info['lock_count']))
|
|
55
|
+
|
|
56
|
+
info_text = (
|
|
57
|
+
f'[bold]Pulice[/bold] v{info["version"]} | '
|
|
58
|
+
f'Backend: {info["task_backend"]} | '
|
|
59
|
+
f'State: {info["state_dir"]}'
|
|
60
|
+
)
|
|
61
|
+
self.query_one('#system-info', Static).update(info_text)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Stacks screen — list and manage stacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.widgets import DataTable, Static
|
|
6
|
+
from pulice.admin.widgets.confirm import ConfirmModal
|
|
7
|
+
|
|
8
|
+
if __name__ != '__main__':
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pulice.admin.data import AdminDataSource
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StacksScreen(Static):
|
|
16
|
+
"""Table of all stacks with lock management."""
|
|
17
|
+
|
|
18
|
+
DEFAULT_CSS = """
|
|
19
|
+
StacksScreen {
|
|
20
|
+
height: 1fr;
|
|
21
|
+
padding: 1 2;
|
|
22
|
+
}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
BINDINGS = [
|
|
26
|
+
('l', 'release_lock', 'Release Lock'),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
def __init__(self, data_source: 'AdminDataSource') -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self._data = data_source
|
|
32
|
+
self._tenant_filter: str | None = None
|
|
33
|
+
|
|
34
|
+
def compose(self) -> ComposeResult:
|
|
35
|
+
table = DataTable(id='stacks-table')
|
|
36
|
+
table.cursor_type = 'row'
|
|
37
|
+
table.add_columns('Stack Name', 'Tenant', 'UUID', 'Locked', 'Created')
|
|
38
|
+
yield table
|
|
39
|
+
|
|
40
|
+
def refresh_data(self) -> None:
|
|
41
|
+
table = self.query_one('#stacks-table', DataTable)
|
|
42
|
+
table.clear()
|
|
43
|
+
stacks = self._data.get_stacks(tenant_id=self._tenant_filter)
|
|
44
|
+
locks = {lock['stack_name']: lock for lock in self._data.get_locks()}
|
|
45
|
+
tenants = {t.id: t.name for t in self._data.get_tenants()}
|
|
46
|
+
|
|
47
|
+
for s in stacks:
|
|
48
|
+
stack_name = s['stack_name']
|
|
49
|
+
lock = locks.get(stack_name)
|
|
50
|
+
lock_display = f'[red]Locked ({lock["operation"]})[/red]' if lock else ''
|
|
51
|
+
tenant_name = tenants.get(s.get('tenant_id', ''), '—')
|
|
52
|
+
|
|
53
|
+
table.add_row(
|
|
54
|
+
stack_name,
|
|
55
|
+
tenant_name,
|
|
56
|
+
s['uuid'][:12],
|
|
57
|
+
lock_display,
|
|
58
|
+
s.get('created_at', '')[:19],
|
|
59
|
+
key=stack_name,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def action_release_lock(self) -> None:
|
|
63
|
+
table = self.query_one('#stacks-table', DataTable)
|
|
64
|
+
if table.row_count == 0:
|
|
65
|
+
return
|
|
66
|
+
row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
|
|
67
|
+
stack_name = str(row_key)
|
|
68
|
+
|
|
69
|
+
locks = {lock['stack_name']: lock for lock in self._data.get_locks()}
|
|
70
|
+
if stack_name not in locks:
|
|
71
|
+
self.notify('No lock on this stack', severity='warning')
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
def on_confirm(confirmed: bool) -> None:
|
|
75
|
+
if confirmed:
|
|
76
|
+
self._data.release_lock(stack_name)
|
|
77
|
+
self.notify(f"Released lock on '{stack_name}'")
|
|
78
|
+
self.refresh_data()
|
|
79
|
+
|
|
80
|
+
self.app.push_screen( # pyrefly: ignore
|
|
81
|
+
ConfirmModal(
|
|
82
|
+
f"Release lock on '{stack_name}'?",
|
|
83
|
+
title='Release Lock',
|
|
84
|
+
),
|
|
85
|
+
on_confirm,
|
|
86
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""System screen — read-only information panel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
if __name__ != '__main__':
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pulice.admin.data import AdminDataSource
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _format_bytes(size: int) -> str:
|
|
15
|
+
for unit in ('B', 'KB', 'MB', 'GB'):
|
|
16
|
+
if size < 1024:
|
|
17
|
+
return f'{size:.1f} {unit}'
|
|
18
|
+
size //= 1024
|
|
19
|
+
return f'{size:.1f} TB'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SystemScreen(Static):
|
|
23
|
+
"""Read-only system information panel."""
|
|
24
|
+
|
|
25
|
+
DEFAULT_CSS = """
|
|
26
|
+
SystemScreen {
|
|
27
|
+
height: 1fr;
|
|
28
|
+
padding: 1 2;
|
|
29
|
+
}
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, data_source: 'AdminDataSource') -> None:
|
|
33
|
+
super().__init__()
|
|
34
|
+
self._data = data_source
|
|
35
|
+
|
|
36
|
+
def compose(self) -> ComposeResult:
|
|
37
|
+
yield Static(id='system-content')
|
|
38
|
+
|
|
39
|
+
def refresh_data(self) -> None:
|
|
40
|
+
info = self._data.get_system_info()
|
|
41
|
+
|
|
42
|
+
locks_text = ''
|
|
43
|
+
if info['locks']:
|
|
44
|
+
lock_lines = []
|
|
45
|
+
for lock in info['locks']:
|
|
46
|
+
age_min = int(lock['age_seconds']) // 60
|
|
47
|
+
lock_lines.append(f' - {lock["stack_name"]} ({lock["operation"]}, {age_min}m ago)')
|
|
48
|
+
locks_text = '\n'.join(lock_lines)
|
|
49
|
+
else:
|
|
50
|
+
locks_text = ' None'
|
|
51
|
+
|
|
52
|
+
text = (
|
|
53
|
+
f'[bold]Version:[/bold] {info["version"]}\n'
|
|
54
|
+
f'[bold]Python:[/bold] {info["python"].split()[0]}\n'
|
|
55
|
+
f'[bold]State Dir:[/bold] {info["state_dir"]}\n'
|
|
56
|
+
f'[bold]State Size:[/bold] {_format_bytes(info["state_dir_size"])}\n'
|
|
57
|
+
f'[bold]DB Size:[/bold] {_format_bytes(info["db_size"])}\n'
|
|
58
|
+
f'[bold]Task Backend:[/bold] {info["task_backend"]}\n'
|
|
59
|
+
f'\n'
|
|
60
|
+
f'[bold]Database Stats:[/bold]\n'
|
|
61
|
+
f' Tenants: {info["tenant_count"]}\n'
|
|
62
|
+
f' Stacks: {info["stack_count"]}\n'
|
|
63
|
+
f' Locks: {info["lock_count"]}\n'
|
|
64
|
+
f'\n'
|
|
65
|
+
f'[bold]Active Locks:[/bold]\n{locks_text}'
|
|
66
|
+
)
|
|
67
|
+
self.query_one('#system-content', Static).update(text)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Tasks screen — list and manage async tasks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.widgets import DataTable, Static
|
|
6
|
+
from pulice.admin.widgets.confirm import ConfirmModal
|
|
7
|
+
|
|
8
|
+
if __name__ != '__main__':
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pulice.admin.data import AdminDataSource
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TasksScreen(Static):
|
|
16
|
+
"""Table of tasks with cancel/retry actions.
|
|
17
|
+
|
|
18
|
+
Note: The current HueyTaskBackend does not support listing all tasks.
|
|
19
|
+
This screen shows a placeholder until a task index is implemented.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
DEFAULT_CSS = """
|
|
23
|
+
TasksScreen {
|
|
24
|
+
height: 1fr;
|
|
25
|
+
padding: 1 2;
|
|
26
|
+
}
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
BINDINGS = [
|
|
30
|
+
('c', 'cancel_task', 'Cancel'),
|
|
31
|
+
('r', 'retry_task', 'Retry'),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
def __init__(self, data_source: 'AdminDataSource') -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._data = data_source
|
|
37
|
+
|
|
38
|
+
def compose(self) -> ComposeResult:
|
|
39
|
+
yield Static(
|
|
40
|
+
'[dim]Task listing requires a task index (not yet implemented in the '
|
|
41
|
+
'Huey backend). Individual tasks can be queried by ID.[/dim]',
|
|
42
|
+
id='tasks-notice',
|
|
43
|
+
)
|
|
44
|
+
table = DataTable(id='tasks-table')
|
|
45
|
+
table.cursor_type = 'row'
|
|
46
|
+
table.add_columns('Task ID', 'Status', 'Error')
|
|
47
|
+
yield table
|
|
48
|
+
|
|
49
|
+
def refresh_data(self) -> None:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def action_cancel_task(self) -> None:
|
|
53
|
+
table = self.query_one('#tasks-table', DataTable)
|
|
54
|
+
if table.row_count == 0:
|
|
55
|
+
return
|
|
56
|
+
row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
|
|
57
|
+
task_id = str(row_key)
|
|
58
|
+
|
|
59
|
+
def on_confirm(confirmed: bool) -> None:
|
|
60
|
+
if confirmed:
|
|
61
|
+
success = self._data.cancel_task(task_id)
|
|
62
|
+
if success:
|
|
63
|
+
self.notify(f"Cancelled task '{task_id[:12]}'")
|
|
64
|
+
else:
|
|
65
|
+
self.notify('Could not cancel task', severity='error')
|
|
66
|
+
self.refresh_data()
|
|
67
|
+
|
|
68
|
+
self.app.push_screen( # pyrefly: ignore
|
|
69
|
+
ConfirmModal(f"Cancel task '{task_id[:12]}'?", title='Cancel Task'),
|
|
70
|
+
on_confirm,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def action_retry_task(self) -> None:
|
|
74
|
+
table = self.query_one('#tasks-table', DataTable)
|
|
75
|
+
if table.row_count == 0:
|
|
76
|
+
return
|
|
77
|
+
row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
|
|
78
|
+
task_id = str(row_key)
|
|
79
|
+
|
|
80
|
+
def on_confirm(confirmed: bool) -> None:
|
|
81
|
+
if confirmed:
|
|
82
|
+
try:
|
|
83
|
+
new_id = self._data.retry_task(task_id)
|
|
84
|
+
self.notify(f"Retried as '{new_id[:12]}'")
|
|
85
|
+
self.refresh_data()
|
|
86
|
+
except ValueError as e:
|
|
87
|
+
self.notify(str(e), severity='error')
|
|
88
|
+
|
|
89
|
+
self.app.push_screen( # pyrefly: ignore
|
|
90
|
+
ConfirmModal(f"Retry task '{task_id[:12]}'?", title='Retry Task'),
|
|
91
|
+
on_confirm,
|
|
92
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Tenants screen — list and manage tenants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.widgets import DataTable, Static
|
|
6
|
+
from pulice.admin.widgets.confirm import ConfirmModal
|
|
7
|
+
|
|
8
|
+
if __name__ != '__main__':
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pulice.admin.data import AdminDataSource
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TenantsScreen(Static):
|
|
16
|
+
"""Table of all tenants with actions."""
|
|
17
|
+
|
|
18
|
+
DEFAULT_CSS = """
|
|
19
|
+
TenantsScreen {
|
|
20
|
+
height: 1fr;
|
|
21
|
+
padding: 1 2;
|
|
22
|
+
}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
BINDINGS = [
|
|
26
|
+
('d', 'delete_tenant', 'Delete'),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
def __init__(self, data_source: 'AdminDataSource') -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self._data = data_source
|
|
32
|
+
|
|
33
|
+
def compose(self) -> ComposeResult:
|
|
34
|
+
table = DataTable(id='tenants-table')
|
|
35
|
+
table.cursor_type = 'row'
|
|
36
|
+
table.add_columns('Name', 'ID', 'Stacks', 'Created')
|
|
37
|
+
yield table
|
|
38
|
+
|
|
39
|
+
def refresh_data(self) -> None:
|
|
40
|
+
table = self.query_one('#tenants-table', DataTable)
|
|
41
|
+
table.clear()
|
|
42
|
+
tenants = self._data.get_tenants()
|
|
43
|
+
stacks = self._data.get_stacks()
|
|
44
|
+
|
|
45
|
+
stack_counts: dict[str, int] = {}
|
|
46
|
+
for s in stacks:
|
|
47
|
+
tid = s.get('tenant_id', '')
|
|
48
|
+
stack_counts[tid] = stack_counts.get(tid, 0) + 1
|
|
49
|
+
|
|
50
|
+
for t in tenants:
|
|
51
|
+
table.add_row(
|
|
52
|
+
t.name,
|
|
53
|
+
t.id[:12],
|
|
54
|
+
str(stack_counts.get(t.id, 0)),
|
|
55
|
+
t.created_at[:19],
|
|
56
|
+
key=t.name,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def action_delete_tenant(self) -> None:
|
|
60
|
+
table = self.query_one('#tenants-table', DataTable)
|
|
61
|
+
if table.row_count == 0:
|
|
62
|
+
return
|
|
63
|
+
row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
|
|
64
|
+
tenant_name = str(row_key)
|
|
65
|
+
|
|
66
|
+
def on_confirm(confirmed: bool) -> None:
|
|
67
|
+
if confirmed:
|
|
68
|
+
try:
|
|
69
|
+
self._data.delete_tenant(tenant_name)
|
|
70
|
+
self.notify(f"Deleted tenant '{tenant_name}'")
|
|
71
|
+
self.refresh_data()
|
|
72
|
+
except ValueError as e:
|
|
73
|
+
self.notify(str(e), severity='error')
|
|
74
|
+
|
|
75
|
+
self.app.push_screen( # pyrefly: ignore
|
|
76
|
+
ConfirmModal(f"Delete tenant '{tenant_name}'?", title='Delete Tenant'),
|
|
77
|
+
on_confirm,
|
|
78
|
+
)
|