fly-ftui 0.1.0__tar.gz

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.
fly_ftui-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fly.io Community
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: fly-ftui
3
+ Version: 0.1.0
4
+ Summary: A terminal UI for managing Fly.io machines
5
+ Author: Fly.io Community
6
+ License-File: LICENSE
7
+ Keywords: cli,fly,flyio,textual,tui
8
+ Classifier: Environment :: Console
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.12
13
+ Requires-Dist: click>=8.3.1
14
+ Requires-Dist: rich>=14.3.3
15
+ Requires-Dist: textual>=8.1.1
16
+ Description-Content-Type: text/markdown
17
+
18
+ # ftui - Fly.io Terminal UI
19
+
20
+ A Python-based TUI for managing your Fly.io machines.
21
+
22
+ ## Features
23
+ - **List Machines:** View all machines in your current Fly app context.
24
+ - **Real-time Logs:** Stream logs for specific machines.
25
+ - **Scaling:** Quickly scale machine count and VM size.
26
+ - **SSH Access:** Interactive shell access to your machines.
27
+ - **Machine Control:** Start and stop machines directly from the UI.
28
+
29
+ ## Installation
30
+ Requires [uv](https://github.com/astral-sh/uv).
31
+
32
+ ```bash
33
+ uv tool install .
34
+ ```
35
+
36
+ ## Usage
37
+ Run in a directory with a `fly.toml` file:
38
+
39
+ ```bash
40
+ ftui
41
+ ```
42
+
43
+ For development/testing without a live account:
44
+ ```bash
45
+ ftui --mock
46
+ ```
47
+
48
+ ## Keybindings
49
+ - `r`: Refresh machine list
50
+ - `l`: View logs for selected machine
51
+ - `s`: Scale app (count/VM size)
52
+ - `h`: SSH into selected machine
53
+ - `Ctrl+s`: Start selected machine
54
+ - `Ctrl+x`: Stop selected machine
55
+ - `q`: Quit / Back
@@ -0,0 +1,38 @@
1
+ # ftui - Fly.io Terminal UI
2
+
3
+ A Python-based TUI for managing your Fly.io machines.
4
+
5
+ ## Features
6
+ - **List Machines:** View all machines in your current Fly app context.
7
+ - **Real-time Logs:** Stream logs for specific machines.
8
+ - **Scaling:** Quickly scale machine count and VM size.
9
+ - **SSH Access:** Interactive shell access to your machines.
10
+ - **Machine Control:** Start and stop machines directly from the UI.
11
+
12
+ ## Installation
13
+ Requires [uv](https://github.com/astral-sh/uv).
14
+
15
+ ```bash
16
+ uv tool install .
17
+ ```
18
+
19
+ ## Usage
20
+ Run in a directory with a `fly.toml` file:
21
+
22
+ ```bash
23
+ ftui
24
+ ```
25
+
26
+ For development/testing without a live account:
27
+ ```bash
28
+ ftui --mock
29
+ ```
30
+
31
+ ## Keybindings
32
+ - `r`: Refresh machine list
33
+ - `l`: View logs for selected machine
34
+ - `s`: Scale app (count/VM size)
35
+ - `h`: SSH into selected machine
36
+ - `Ctrl+s`: Start selected machine
37
+ - `Ctrl+x`: Stop selected machine
38
+ - `q`: Quit / Back
@@ -0,0 +1,252 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ from typing import Dict, Any, List
5
+
6
+ from textual.app import App, ComposeResult
7
+ from textual.widgets import Header, Footer, DataTable, Static, RichLog, Input, Label
8
+ from textual.screen import Screen, ModalScreen
9
+ from textual.containers import Container, Horizontal, Vertical
10
+ from textual.binding import Binding
11
+
12
+ from ftui.fly_client import FlyClient
13
+
14
+ class MachineListScreen(Screen):
15
+ """Screen for listing and managing Fly machines."""
16
+
17
+ BINDINGS = [
18
+ Binding("r", "refresh", "Refresh", show=True),
19
+ Binding("l", "view_logs", "Logs", show=True),
20
+ Binding("s", "scale", "Scale", show=True),
21
+ Binding("h", "ssh", "SSH", show=True),
22
+ Binding("ctrl+s", "start_machine", "Start", show=True),
23
+ Binding("ctrl+x", "stop_machine", "Stop", show=True),
24
+ ]
25
+
26
+ def __init__(self, fly_client: FlyClient):
27
+ super().__init__()
28
+ self.fly = fly_client
29
+
30
+ def compose(self) -> ComposeResult:
31
+ yield Header()
32
+ yield DataTable(id="machine-table", cursor_type="row")
33
+ yield Footer()
34
+
35
+ async def on_mount(self) -> None:
36
+ table = self.query_one(DataTable)
37
+ table.add_columns("ID", "Name", "State", "Region", "Image", "Created")
38
+ await self.action_refresh()
39
+ # Set a timer for periodic refresh
40
+ self.set_interval(10, self.action_refresh)
41
+
42
+ async def action_refresh(self) -> None:
43
+ """Fetch machine list and update table."""
44
+ table = self.query_one(DataTable)
45
+ try:
46
+ machines = await self.fly.list_machines()
47
+ table.clear()
48
+ for m in machines:
49
+ table.add_row(
50
+ m.get("id", ""),
51
+ m.get("name", ""),
52
+ m.get("state", ""),
53
+ m.get("region", ""),
54
+ m.get("image", ""),
55
+ m.get("created_at", "")[:19] # Truncate timestamp
56
+ )
57
+ except Exception as e:
58
+ self.notify(f"Error fetching machines: {e}", severity="error")
59
+
60
+ def get_selected_machine(self) -> str:
61
+ """Get the ID of the selected machine in the table."""
62
+ table = self.query_one(DataTable)
63
+ if table.cursor_row is not None:
64
+ row = table.get_row_at(table.cursor_row)
65
+ return row[0]
66
+ return None
67
+
68
+ async def action_view_logs(self) -> None:
69
+ machine_id = self.get_selected_machine()
70
+ if machine_id:
71
+ self.app.push_screen(LogScreen(self.fly, machine_id))
72
+ else:
73
+ self.notify("No machine selected")
74
+
75
+ async def action_scale(self) -> None:
76
+ self.app.push_screen(ScaleDialog(self.fly))
77
+
78
+ async def action_ssh(self) -> None:
79
+ machine_id = self.get_selected_machine()
80
+ if not machine_id:
81
+ self.notify("No machine selected")
82
+ return
83
+
84
+ # We need to suspend the TUI to run an interactive shell
85
+ self.app.suspend_stdio()
86
+ try:
87
+ print(f"Connecting to {machine_id}...")
88
+ # Use os.system for simple handover to the interactive fly ssh command
89
+ cmd = f"fly ssh console -s -m {machine_id}"
90
+ if self.fly.mock:
91
+ print(f"MOCK: {cmd}")
92
+ input("Press Enter to return to FTUI...")
93
+ else:
94
+ os.system(cmd)
95
+ finally:
96
+ self.app.resume_stdio()
97
+
98
+ async def action_start_machine(self) -> None:
99
+ machine_id = self.get_selected_machine()
100
+ if machine_id:
101
+ try:
102
+ await self.fly.start_machine(machine_id)
103
+ self.notify(f"Machine {machine_id} started")
104
+ await self.action_refresh()
105
+ except Exception as e:
106
+ self.notify(f"Failed to start machine: {e}", severity="error")
107
+
108
+ async def action_stop_machine(self) -> None:
109
+ machine_id = self.get_selected_machine()
110
+ if machine_id:
111
+ try:
112
+ await self.fly.stop_machine(machine_id)
113
+ self.notify(f"Machine {machine_id} stopped")
114
+ await self.action_refresh()
115
+ except Exception as e:
116
+ self.notify(f"Failed to stop machine: {e}", severity="error")
117
+
118
+ class LogScreen(Screen):
119
+ """Screen for viewing machine logs."""
120
+
121
+ BINDINGS = [
122
+ Binding("q", "back", "Back", show=True),
123
+ Binding("c", "clear", "Clear", show=True),
124
+ ]
125
+
126
+ def __init__(self, fly_client: FlyClient, machine_id: str):
127
+ super().__init__()
128
+ self.fly = fly_client
129
+ self.machine_id = machine_id
130
+ self.process = None
131
+
132
+ def compose(self) -> ComposeResult:
133
+ yield Header()
134
+ yield RichLog(id="log-viewer", highlight=True, markup=True)
135
+ yield Footer()
136
+
137
+ async def on_mount(self) -> None:
138
+ log_viewer = self.query_one(RichLog)
139
+ log_viewer.write(f"Tail logs for machine [bold cyan]{self.machine_id}[/bold cyan]...")
140
+
141
+ # Start log tailing
142
+ asyncio.create_task(self.stream_logs())
143
+
144
+ async def stream_logs(self):
145
+ log_viewer = self.query_one(RichLog)
146
+
147
+ if self.fly.mock:
148
+ while True:
149
+ log_viewer.write(f"[{self.machine_id}] Simulated log message {random.randint(100,999)}")
150
+ await asyncio.sleep(1)
151
+ if self.is_current_screen is False:
152
+ break
153
+ return
154
+
155
+ args = ["logs", "--instance", self.machine_id]
156
+ self.process = await asyncio.create_subprocess_exec(
157
+ "fly", *args,
158
+ stdout=asyncio.subprocess.PIPE,
159
+ stderr=asyncio.subprocess.STDOUT
160
+ )
161
+
162
+ while True:
163
+ line = await self.process.stdout.readline()
164
+ if not line:
165
+ break
166
+ log_viewer.write(line.decode().strip())
167
+
168
+ def action_back(self) -> None:
169
+ if self.process:
170
+ self.process.terminate()
171
+ self.app.pop_screen()
172
+
173
+ def action_clear(self) -> None:
174
+ self.query_one(RichLog).clear()
175
+
176
+ class ScaleDialog(ModalScreen):
177
+ """Modal for scaling applications."""
178
+
179
+ def compose(self) -> ComposeResult:
180
+ with Container(id="dialog"):
181
+ yield Label("Scale Application", id="title")
182
+ yield Label("Machine Count:")
183
+ yield Input(placeholder="Enter count (e.g. 3)", id="count-input")
184
+ yield Label("VM Size:")
185
+ yield Input(placeholder="Enter size (e.g. shared-cpu-1x)", id="vm-input")
186
+ with Horizontal():
187
+ yield Static(id="spacer")
188
+ yield Vertical(
189
+ Horizontal(
190
+ Static(" "),
191
+ Static("[b]Enter[/b] to Apply, [b]Esc[/b] to Cancel", id="help-text"),
192
+ classes="dialog-buttons"
193
+ )
194
+ )
195
+
196
+ def on_key(self, event) -> None:
197
+ if event.key == "escape":
198
+ self.app.pop_screen()
199
+
200
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
201
+ count_val = self.query_one("#count-input", Input).value
202
+ vm_val = self.query_one("#vm-input", Input).value
203
+
204
+ fly = FlyClient() # Re-using FlyClient
205
+
206
+ try:
207
+ if count_val:
208
+ await fly.scale_count(int(count_val))
209
+ self.notify(f"Scaling count to {count_val}...")
210
+
211
+ if vm_val:
212
+ await fly.scale_vm(vm_val)
213
+ self.notify(f"Scaling VM size to {vm_val}...")
214
+
215
+ self.app.pop_screen()
216
+ except Exception as e:
217
+ self.notify(f"Scale error: {e}", severity="error")
218
+
219
+ class FTUI(App):
220
+ """Main Fly.io TUI application."""
221
+
222
+ CSS = """
223
+ #dialog {
224
+ width: 40;
225
+ height: auto;
226
+ border: thick $primary;
227
+ background: $surface;
228
+ padding: 1 2;
229
+ align: center middle;
230
+ }
231
+ #title {
232
+ text-align: center;
233
+ width: 100%;
234
+ margin-bottom: 1;
235
+ background: $primary;
236
+ color: $on-primary;
237
+ }
238
+ .dialog-buttons {
239
+ margin-top: 1;
240
+ }
241
+ #spacer {
242
+ width: 1fr;
243
+ }
244
+ """
245
+
246
+ def on_mount(self) -> None:
247
+ self.fly = FlyClient()
248
+ self.push_screen(MachineListScreen(self.fly))
249
+
250
+ if __name__ == "__main__":
251
+ app = FTUI()
252
+ app.run()
@@ -0,0 +1,107 @@
1
+ import json
2
+ import asyncio
3
+ import os
4
+ import random
5
+ from typing import List, Dict, Any, Optional
6
+
7
+ class FlyClient:
8
+ """Wrapper for flyctl commands."""
9
+
10
+ def __init__(self, mock: bool = False):
11
+ self.mock = mock or os.getenv("FTUI_MOCK") == "1"
12
+
13
+ async def _run(self, args: List[str]) -> str:
14
+ """Run a flyctl command and return its output."""
15
+ if self.mock:
16
+ return await self._mock_run(args)
17
+
18
+ proc = await asyncio.create_subprocess_exec(
19
+ "fly", *args,
20
+ stdout=asyncio.subprocess.PIPE,
21
+ stderr=asyncio.subprocess.PIPE
22
+ )
23
+ stdout, stderr = await proc.communicate()
24
+
25
+ if proc.returncode != 0:
26
+ raise Exception(f"flyctl error: {stderr.decode()}")
27
+
28
+ return stdout.decode()
29
+
30
+ async def _mock_run(self, args: List[str]) -> str:
31
+ """Simulate flyctl command outputs."""
32
+ await asyncio.sleep(0.1) # Simulate network latency
33
+
34
+ cmd = " ".join(args)
35
+ if "machines list" in cmd and "--json" in cmd:
36
+ return json.dumps([
37
+ {
38
+ "id": "148ed106c62289",
39
+ "name": "gentle-sun-42",
40
+ "state": "started",
41
+ "region": "ams",
42
+ "image": "flyio/hellofly:latest",
43
+ "created_at": "2023-10-01T12:00:00Z"
44
+ },
45
+ {
46
+ "id": "7811d615b04489",
47
+ "name": "bitter-cloud-12",
48
+ "state": "stopped",
49
+ "region": "ams",
50
+ "image": "flyio/hellofly:latest",
51
+ "created_at": "2023-10-02T10:00:00Z"
52
+ },
53
+ {
54
+ "id": "9080e21fb54287",
55
+ "name": "patient-field-77",
56
+ "state": "started",
57
+ "region": "lhr",
58
+ "image": "flyio/hellofly:latest",
59
+ "created_at": "2023-10-03T08:30:00Z"
60
+ }
61
+ ])
62
+
63
+ if "scale show" in cmd and "--json" in cmd:
64
+ return json.dumps({
65
+ "ProcessGroup": "app",
66
+ "Count": 3,
67
+ "CPUs": 1,
68
+ "Memory": 256,
69
+ "VMSize": "shared-cpu-1x"
70
+ })
71
+
72
+ return ""
73
+
74
+ async def list_machines(self) -> List[Dict[str, Any]]:
75
+ """List machines for the current app."""
76
+ output = await self._run(["machines", "list", "--json"])
77
+ if not output.strip():
78
+ return []
79
+ return json.loads(output)
80
+
81
+ async def get_scale(self) -> Dict[str, Any]:
82
+ """Get scaling information for the current app."""
83
+ # Using machine list to infer count and VM size as 'scale show' can be complex to parse
84
+ # but let's try 'scale show --json' if supported or mock it.
85
+ try:
86
+ output = await self._run(["scale", "show", "--json"])
87
+ return json.loads(output)
88
+ except:
89
+ # Fallback for older flyctl versions or if json output is not available
90
+ machines = await self.list_machines()
91
+ return {"Count": len(machines), "VMSize": "unknown"}
92
+
93
+ async def scale_count(self, count: int):
94
+ """Scale the machine count."""
95
+ await self._run(["scale", "count", str(count), "--yes"])
96
+
97
+ async def scale_vm(self, size: str):
98
+ """Scale the VM size."""
99
+ await self._run(["scale", "vm", size, "--yes"])
100
+
101
+ async def stop_machine(self, machine_id: str):
102
+ """Stop a specific machine."""
103
+ await self._run(["machine", "stop", machine_id])
104
+
105
+ async def start_machine(self, machine_id: str):
106
+ """Start a specific machine."""
107
+ await self._run(["machine", "start", machine_id])
@@ -0,0 +1,17 @@
1
+ import click
2
+ import os
3
+ from ftui.app import FTUI
4
+ from ftui.fly_client import FlyClient
5
+
6
+ @click.command()
7
+ @click.option("--mock", is_flag=True, help="Use mock flyctl for development")
8
+ def main(mock: bool):
9
+ """Fly.io Terminal UI (ftui)"""
10
+ if mock:
11
+ os.environ["FTUI_MOCK"] = "1"
12
+
13
+ app = FTUI()
14
+ app.run()
15
+
16
+ if __name__ == "__main__":
17
+ main()
fly_ftui-0.1.0/main.py ADDED
@@ -0,0 +1,6 @@
1
+ def main():
2
+ print("Hello from ftui!")
3
+
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "fly-ftui"
3
+ version = "0.1.0"
4
+ description = "A terminal UI for managing Fly.io machines"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ authors = [
8
+ { name = "Fly.io Community" }
9
+ ]
10
+ keywords = ["fly", "flyio", "tui", "textual", "cli"]
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ "Environment :: Console",
16
+ ]
17
+ dependencies = [
18
+ "textual>=8.1.1",
19
+ "rich>=14.3.3",
20
+ "click>=8.3.1",
21
+ ]
22
+
23
+ [project.scripts]
24
+ ftui = "ftui.main:main"
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/ftui"]