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 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
+ ]
@@ -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)
@@ -0,0 +1,8 @@
1
+ """Allow running the admin TUI with: python -m pulice.admin"""
2
+
3
+ import sys
4
+ from pulice.admin.app import PuliceAdmin
5
+
6
+ state_dir = sys.argv[1] if len(sys.argv) > 1 else None
7
+ app = PuliceAdmin(state_dir=state_dir)
8
+ app.run()
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
+ )