bitsreef-cli 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.
@@ -0,0 +1,138 @@
1
+ """Environment variable commands: list, set, delete."""
2
+ import click
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ from ..client import get_client
7
+ from .. import context, output
8
+
9
+ console = Console()
10
+
11
+
12
+ def _service(client, service):
13
+ """Resolve --service (id/name) or the linked service."""
14
+ link = context.read_link()
15
+ return context.resolve_service(client, link.get("project_id"), service, link)
16
+
17
+
18
+ @click.group()
19
+ def env():
20
+ """Manage environment variables for a service."""
21
+
22
+
23
+ @env.command("list")
24
+ @click.option("--service", "-s", default=None, help="Service id or name (else linked)")
25
+ def list_vars(service):
26
+ """List environment variables for a service."""
27
+ client = get_client()
28
+ service_id = _service(client, service)
29
+ resp = client.get(f"/services/{service_id}/env/")
30
+ if resp.status_code != 200:
31
+ console.print("[red]Failed to fetch variables.[/red]")
32
+ raise SystemExit(1)
33
+
34
+ results = resp.json().get("results", [])
35
+ if output.json_enabled():
36
+ # Redact secret values in JSON too (reveal has its own command).
37
+ output.emit([{**v, "value": ("********" if v.get("is_secret") else v.get("value"))} for v in results])
38
+ return
39
+ if not results:
40
+ console.print("[dim]No environment variables.[/dim]")
41
+ return
42
+
43
+ table = Table(title="Environment Variables")
44
+ table.add_column("ID", style="dim")
45
+ table.add_column("Key", style="bold")
46
+ table.add_column("Value")
47
+ table.add_column("Secret")
48
+ for v in results:
49
+ val = "********" if v["is_secret"] else v["value"]
50
+ table.add_row(str(v["id"]), v["key"], val, "Yes" if v["is_secret"] else "No")
51
+ console.print(table)
52
+
53
+
54
+ @env.command("set")
55
+ @click.argument("key")
56
+ @click.argument("value")
57
+ @click.option("--service", "-s", default=None, help="Service id or name (else linked)")
58
+ @click.option("--secret", is_flag=True, help="Mark as secret")
59
+ def set_var(key, value, service, secret):
60
+ """Set an environment variable (creates or updates)."""
61
+ client = get_client()
62
+ service_id = _service(client, service)
63
+ # Check if key already exists
64
+ resp = client.get(f"/services/{service_id}/env/")
65
+ existing = None
66
+ for v in resp.json().get("results", []):
67
+ if v["key"] == key:
68
+ existing = v
69
+ break
70
+
71
+ if existing:
72
+ resp = client.patch(
73
+ f"/services/{service_id}/env/{existing['id']}/",
74
+ json={"value": value, "is_secret": secret},
75
+ )
76
+ if resp.status_code == 200:
77
+ console.print(f"[green]Updated {key}[/green]")
78
+ else:
79
+ console.print(f"[red]Failed: {resp.text}[/red]")
80
+ else:
81
+ resp = client.post(
82
+ f"/services/{service_id}/env/",
83
+ json={"key": key, "value": value, "is_secret": secret},
84
+ )
85
+ if resp.status_code == 201:
86
+ console.print(f"[green]Set {key}[/green]")
87
+ else:
88
+ console.print(f"[red]Failed: {resp.text}[/red]")
89
+
90
+
91
+ @env.command("delete")
92
+ @click.argument("key")
93
+ @click.option("--service", "-s", default=None, help="Service id or name (else linked)")
94
+ def delete_var(key, service):
95
+ """Delete an environment variable by key."""
96
+ client = get_client()
97
+ service_id = _service(client, service)
98
+ resp = client.get(f"/services/{service_id}/env/")
99
+ target = None
100
+ for v in resp.json().get("results", []):
101
+ if v["key"] == key:
102
+ target = v
103
+ break
104
+
105
+ if not target:
106
+ console.print(f"[yellow]Variable '{key}' not found.[/yellow]")
107
+ return
108
+
109
+ resp = client.delete(f"/services/{service_id}/env/{target['id']}/")
110
+ if resp.status_code == 204:
111
+ console.print(f"[green]Deleted {key}[/green]")
112
+ else:
113
+ console.print(f"[red]Failed: {resp.text}[/red]")
114
+
115
+
116
+ @env.command("reveal")
117
+ @click.argument("key")
118
+ @click.option("--service", "-s", default=None, help="Service id or name (else linked)")
119
+ def reveal_var(key, service):
120
+ """Reveal a secret variable's value."""
121
+ client = get_client()
122
+ service_id = _service(client, service)
123
+ resp = client.get(f"/services/{service_id}/env/")
124
+ target = None
125
+ for v in resp.json().get("results", []):
126
+ if v["key"] == key:
127
+ target = v
128
+ break
129
+
130
+ if not target:
131
+ console.print(f"[yellow]Variable '{key}' not found.[/yellow]")
132
+ return
133
+
134
+ resp = client.get(f"/services/{service_id}/env/{target['id']}/reveal/")
135
+ if resp.status_code == 200:
136
+ console.print(f"[bold]{key}[/bold]={resp.json()['value']}")
137
+ else:
138
+ console.print(f"[red]Failed: {resp.text}[/red]")
@@ -0,0 +1,132 @@
1
+ """Environment commands: list, create (blank or clone), delete.
2
+
3
+ An environment is an independently-running copy of a project's services
4
+ (production, staging, development, …). Creating one can clone an existing
5
+ environment's service definitions in a stopped state for you to configure and
6
+ deploy.
7
+ """
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from ..client import get_client
13
+ from .. import context, output
14
+
15
+ console = Console()
16
+
17
+
18
+ @click.group()
19
+ def environments():
20
+ """Manage project environments (production, staging, …)."""
21
+
22
+
23
+ @environments.command("list")
24
+ @click.option("--project", "-p", default=None, help="Project id or name (else linked)")
25
+ def list_environments(project):
26
+ """List a project's environments."""
27
+ client = get_client()
28
+ link = context.read_link()
29
+ project_id = context.resolve_project(client, project, link)
30
+ resp = client.get(f"/projects/{project_id}/environments/")
31
+ if resp.status_code != 200:
32
+ console.print("[red]Failed to fetch environments.[/red]")
33
+ raise SystemExit(1)
34
+ results = resp.json().get("results", [])
35
+ if output.json_enabled():
36
+ output.emit(results)
37
+ return
38
+ if not results:
39
+ console.print("[dim]No environments.[/dim]")
40
+ return
41
+ table = Table(title="Environments")
42
+ table.add_column("ID", style="dim")
43
+ table.add_column("Name", style="bold")
44
+ table.add_column("Default", justify="center")
45
+ table.add_column("Services", justify="right")
46
+ for e in results:
47
+ table.add_row(
48
+ str(e["id"]),
49
+ e["name"],
50
+ "✓" if e.get("is_default") else "",
51
+ str(e.get("service_count", 0)),
52
+ )
53
+ console.print(table)
54
+
55
+
56
+ @environments.command("create")
57
+ @click.argument("name")
58
+ @click.option("--project", "-p", default=None, help="Project id or name (else linked)")
59
+ @click.option(
60
+ "--clone-from",
61
+ default=None,
62
+ help="Source environment id/name to clone services from (stopped).",
63
+ )
64
+ @click.option(
65
+ "--blank",
66
+ is_flag=True,
67
+ help="Create an empty environment (no services). Default clones production.",
68
+ )
69
+ def create_environment(name, project, clone_from, blank):
70
+ """Create a new environment.
71
+
72
+ By default clones the project's production environment (services copied in a
73
+ stopped state — configure and deploy them). Use --clone-from to pick a
74
+ different source, or --blank for an empty environment.
75
+ """
76
+ client = get_client()
77
+ link = context.read_link()
78
+ project_id = context.resolve_project(client, project, link)
79
+
80
+ payload = {"name": name}
81
+ if not blank:
82
+ source = clone_from if clone_from is not None else None
83
+ source_id = context.resolve_environment(client, project_id, source)
84
+ payload["clone_from"] = source_id
85
+
86
+ resp = client.post(f"/projects/{project_id}/environments/", json=payload)
87
+ if resp.status_code not in (200, 201):
88
+ detail = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text
89
+ if output.json_enabled():
90
+ output.emit({"error": detail})
91
+ else:
92
+ console.print(f"[red]Failed to create environment:[/red] {detail}")
93
+ raise SystemExit(1)
94
+
95
+ data = resp.json()
96
+ if output.json_enabled():
97
+ output.emit(data)
98
+ return
99
+ if blank:
100
+ console.print(f"[green]Created blank environment[/green] '{data['name']}' (id {data['id']}).")
101
+ else:
102
+ console.print(
103
+ f"[green]Created environment[/green] '{data['name']}' (id {data['id']}) — "
104
+ "services cloned in a stopped state. Configure and deploy them, e.g. "
105
+ f"`bitsreef services list -p {project_id} --environment {data['id']}`."
106
+ )
107
+
108
+
109
+ @environments.command("delete")
110
+ @click.argument("environment")
111
+ @click.option("--project", "-p", default=None, help="Project id or name (else linked)")
112
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
113
+ def delete_environment(environment, project, yes):
114
+ """Delete an environment (and its services). The default env can't be deleted."""
115
+ client = get_client()
116
+ link = context.read_link()
117
+ project_id = context.resolve_project(client, project, link)
118
+ env_id = context.resolve_environment(client, project_id, environment)
119
+ if not yes and not output.json_enabled():
120
+ click.confirm(
121
+ f"Delete environment {environment} and all its services?", abort=True
122
+ )
123
+ resp = client.delete(f"/projects/{project_id}/environments/{env_id}/")
124
+ if resp.status_code not in (200, 204):
125
+ detail = resp.text
126
+ try:
127
+ detail = resp.json()
128
+ except Exception:
129
+ pass
130
+ console.print(f"[red]Failed to delete environment:[/red] {detail}")
131
+ raise SystemExit(1)
132
+ console.print(f"[green]Deleted environment[/green] {environment}.")
@@ -0,0 +1,27 @@
1
+ """`bitsreef exec` — open an interactive shell in a running service container."""
2
+ import click
3
+ from rich.console import Console
4
+
5
+ from ..client import get_client
6
+ from .. import config, context
7
+
8
+ console = Console()
9
+
10
+
11
+ @click.command("exec")
12
+ @click.argument("project_id", required=False, default=None)
13
+ @click.argument("service_id", required=False, default=None)
14
+ def exec_(project_id, service_id):
15
+ """Open an interactive shell (/bin/sh) in the service's running container.
16
+
17
+ Uses the linked project/service if omitted; names/slugs work in place of IDs.
18
+ Type `exit` (or Ctrl-D) to leave the shell.
19
+ """
20
+ client = get_client()
21
+ link = context.read_link()
22
+ project_id = context.resolve_project(client, project_id, link)
23
+ service_id = context.resolve_service(client, project_id, service_id, link)
24
+
25
+ from ..shell import exec_shell
26
+ token = config.get_tokens()[0]
27
+ raise SystemExit(exec_shell(config.get_api_url(), token, service_id))
@@ -0,0 +1,186 @@
1
+ """Function commands: list, create, invoke, delete."""
2
+ import click
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ from ..client import get_client
7
+ from .. import output
8
+
9
+ console = Console()
10
+
11
+
12
+ @click.group()
13
+ def functions():
14
+ """Manage serverless functions."""
15
+
16
+
17
+ @functions.command("list")
18
+ @click.argument("project_id", type=int)
19
+ def list_functions(project_id):
20
+ """List all functions in a project."""
21
+ client = get_client()
22
+ resp = client.get(f"/projects/{project_id}/functions/")
23
+ if resp.status_code != 200:
24
+ console.print("[red]Failed to fetch functions.[/red]")
25
+ raise SystemExit(1)
26
+
27
+ results = resp.json().get("results", [])
28
+ if output.json_enabled():
29
+ output.emit(results)
30
+ return
31
+ if not results:
32
+ console.print("[dim]No functions.[/dim]")
33
+ return
34
+
35
+ table = Table(title="Functions")
36
+ table.add_column("ID", style="dim")
37
+ table.add_column("Name", style="bold")
38
+ table.add_column("Runtime")
39
+ table.add_column("Trigger")
40
+ table.add_column("Enabled")
41
+ table.add_column("Invocations", justify="right")
42
+ for fn in results:
43
+ enabled = "[green]Yes[/green]" if fn["enabled"] else "[red]No[/red]"
44
+ trigger = fn["cron_schedule"] if fn["trigger_type"] == "cron" else "HTTP"
45
+ table.add_row(
46
+ str(fn["id"]), fn["name"], fn["runtime"],
47
+ trigger, enabled, str(fn["invocation_count"]),
48
+ )
49
+ console.print(table)
50
+
51
+
52
+ @functions.command("create")
53
+ @click.argument("project_id", type=int)
54
+ @click.argument("name")
55
+ @click.option("--runtime", "-r", type=click.Choice(["bun", "deno", "python", "node"]), default="python")
56
+ @click.option("--code", "-c", default=None, help="Inline code string")
57
+ @click.option("--file", "-f", "code_file", type=click.Path(exists=True), help="Read code from file")
58
+ @click.option("--trigger", "-t", type=click.Choice(["http", "cron"]), default="http")
59
+ @click.option("--cron", default="", help="Cron schedule (for cron trigger)")
60
+ @click.option("--timeout", default=30, type=int, help="Timeout in seconds")
61
+ @click.option("--memory", default=128, type=int, help="Memory limit in MB")
62
+ def create_function(project_id, name, runtime, code, code_file, trigger, cron, timeout, memory):
63
+ """Create a new function."""
64
+ if code_file:
65
+ with open(code_file) as f:
66
+ code = f.read()
67
+ if not code:
68
+ console.print("[red]Provide --code or --file.[/red]")
69
+ raise SystemExit(1)
70
+
71
+ client = get_client()
72
+ payload = {
73
+ "name": name,
74
+ "runtime": runtime,
75
+ "code": code,
76
+ "trigger_type": trigger,
77
+ "cron_schedule": cron if trigger == "cron" else "",
78
+ "timeout_seconds": timeout,
79
+ "memory_limit_mb": memory,
80
+ }
81
+ resp = client.post(f"/projects/{project_id}/functions/", json=payload)
82
+ if resp.status_code == 201:
83
+ fn = resp.json()
84
+ console.print(f"[green]Created [bold]{fn['name']}[/bold] endpoint={fn['endpoint_path']}[/green]")
85
+ else:
86
+ console.print(f"[red]Failed: {resp.text}[/red]")
87
+
88
+
89
+ @functions.command("invoke")
90
+ @click.argument("project_id", type=int)
91
+ @click.argument("function_id", type=int)
92
+ def invoke_function(project_id, function_id):
93
+ """Invoke a function."""
94
+ client = get_client()
95
+ resp = client.post(f"/projects/{project_id}/functions/{function_id}/invoke/")
96
+ if resp.status_code == 202:
97
+ data = resp.json()
98
+ console.print(f"[green]Invoked — invocation_id={data['invocation_id']}[/green]")
99
+ else:
100
+ console.print(f"[red]Failed: {resp.text}[/red]")
101
+
102
+
103
+ @functions.command("history")
104
+ @click.argument("project_id", type=int)
105
+ @click.argument("function_id", type=int)
106
+ def invocation_history(project_id, function_id):
107
+ """Show invocation history for a function."""
108
+ client = get_client()
109
+ resp = client.get(f"/projects/{project_id}/functions/{function_id}/invocations/")
110
+ if resp.status_code != 200:
111
+ console.print("[red]Failed to fetch invocations.[/red]")
112
+ raise SystemExit(1)
113
+
114
+ results = resp.json().get("results", [])
115
+ if output.json_enabled():
116
+ output.emit(results)
117
+ return
118
+ if not results:
119
+ console.print("[dim]No invocations yet.[/dim]")
120
+ return
121
+
122
+ table = Table(title="Invocation History")
123
+ table.add_column("ID", style="dim")
124
+ table.add_column("Status")
125
+ table.add_column("Duration")
126
+ table.add_column("Trigger")
127
+ table.add_column("Started")
128
+ for inv in results:
129
+ s = inv["status"]
130
+ color = "green" if s == "success" else "red" if s == "failed" else "yellow" if s == "timeout" else "blue"
131
+ dur = f"{inv['duration_ms']}ms" if inv.get("duration_ms") else "—"
132
+ table.add_row(
133
+ str(inv["id"]), f"[{color}]{s}[/{color}]",
134
+ dur, inv["trigger"], inv["started_at"][:19],
135
+ )
136
+ console.print(table)
137
+
138
+
139
+ @functions.command("delete")
140
+ @click.argument("project_id", type=int)
141
+ @click.argument("function_id", type=int)
142
+ @click.confirmation_option(prompt="Delete this function?")
143
+ def delete_function(project_id, function_id):
144
+ """Delete a function."""
145
+ client = get_client()
146
+ resp = client.delete(f"/projects/{project_id}/functions/{function_id}/")
147
+ if resp.status_code == 204:
148
+ console.print("[green]Deleted.[/green]")
149
+ else:
150
+ console.print(f"[red]Failed: {resp.text}[/red]")
151
+
152
+
153
+ @functions.command("update")
154
+ @click.argument("project_id", type=int)
155
+ @click.argument("function_id", type=int)
156
+ @click.option("--code", "-c", default=None, help="New code string")
157
+ @click.option("--file", "-f", "code_file", type=click.Path(exists=True), help="Read new code from file")
158
+ @click.option("--enabled/--disabled", default=None, help="Enable or disable")
159
+ @click.option("--timeout", default=None, type=int, help="Timeout in seconds")
160
+ @click.option("--memory", default=None, type=int, help="Memory limit in MB")
161
+ def update_function(project_id, function_id, code, code_file, enabled, timeout, memory):
162
+ """Update a function's configuration or code."""
163
+ if code_file:
164
+ with open(code_file) as f:
165
+ code = f.read()
166
+
167
+ payload = {}
168
+ if code is not None:
169
+ payload["code"] = code
170
+ if enabled is not None:
171
+ payload["enabled"] = enabled
172
+ if timeout is not None:
173
+ payload["timeout_seconds"] = timeout
174
+ if memory is not None:
175
+ payload["memory_limit_mb"] = memory
176
+
177
+ if not payload:
178
+ console.print("[yellow]Nothing to update.[/yellow]")
179
+ return
180
+
181
+ client = get_client()
182
+ resp = client.patch(f"/projects/{project_id}/functions/{function_id}/", json=payload)
183
+ if resp.status_code == 200:
184
+ console.print("[green]Updated.[/green]")
185
+ else:
186
+ console.print(f"[red]Failed: {resp.text}[/red]")
@@ -0,0 +1,87 @@
1
+ """`bitsreef link` — bind the current directory to a project/service.
2
+
3
+ Once linked, commands can be run without repeating IDs: `bitsreef logs`,
4
+ `bitsreef up -i img:tag`, `bitsreef deploy up`, etc. resolve the target from the
5
+ nearest `.bitsreef.json`. Names/slugs work in place of IDs everywhere too.
6
+ """
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from ..client import get_client
11
+ from .. import context
12
+
13
+ console = Console()
14
+
15
+
16
+ def _project_name(client, org_id, project_id):
17
+ r = client.get(f"/organizations/{org_id}/projects/{project_id}/")
18
+ return r.json().get("name") if r.status_code == 200 else None
19
+
20
+
21
+ def _service_name(client, project_id, service_id):
22
+ r = client.get(f"/projects/{project_id}/services/{service_id}/")
23
+ return r.json().get("name") if r.status_code == 200 else None
24
+
25
+
26
+ @click.command()
27
+ @click.option("--project", "-p", default=None, help="Project id or name")
28
+ @click.option("--service", "-s", default=None, help="Service id or name")
29
+ @click.option("--org", type=int, default=None, help="Organization ID")
30
+ @click.option("--show", is_flag=True, help="Show the current link and exit")
31
+ def link(project, service, org, show):
32
+ """Link this directory to a project (and optionally a service)."""
33
+ if show:
34
+ current = context.read_link()
35
+ if not current:
36
+ console.print("[dim]No link in this directory.[/dim]")
37
+ return
38
+ f = context.find_link_file()
39
+ console.print(f"[bold]Linked[/bold] ({f}):")
40
+ console.print(f" org={current.get('org_id')} "
41
+ f"project={current.get('project_name') or current.get('project_id')} "
42
+ f"service={current.get('service_name') or current.get('service_id') or '—'}")
43
+ return
44
+
45
+ client = get_client()
46
+ link_data = context.read_link()
47
+ org_id = context.resolve_org(client, org, link_data)
48
+
49
+ # Project: explicit id/name, else interactive pick.
50
+ if project is not None:
51
+ project_id = context.resolve_project(client, project, {"org_id": org_id}, org=org_id)
52
+ else:
53
+ projects = context._paginate(client, f"/organizations/{org_id}/projects/")
54
+ if not projects:
55
+ raise click.ClickException("No projects in this org — create one first.")
56
+ console.print("[bold]Select a project:[/bold]")
57
+ for i, p in enumerate(projects, 1):
58
+ console.print(f" {i}. {p['name']} (id={p['id']})")
59
+ choice = click.prompt("Enter number", type=int, default=1)
60
+ project_id = int(projects[choice - 1]["id"])
61
+
62
+ data = {
63
+ "org_id": org_id,
64
+ "project_id": project_id,
65
+ "project_name": _project_name(client, org_id, project_id),
66
+ }
67
+
68
+ # Service is optional.
69
+ if service is not None:
70
+ service_id = context.resolve_service(client, project_id, service)
71
+ data["service_id"] = service_id
72
+ data["service_name"] = _service_name(client, project_id, service_id)
73
+
74
+ path = context.write_link(data)
75
+ console.print(f"[green]Linked[/green] → {data['project_name'] or project_id}"
76
+ + (f" / {data.get('service_name') or data.get('service_id')}" if service is not None else "")
77
+ + f" [dim]({path})[/dim]")
78
+
79
+
80
+ @click.command()
81
+ def unlink():
82
+ """Remove this directory's link."""
83
+ removed = context.clear_link()
84
+ if removed:
85
+ console.print(f"[green]Unlinked[/green] [dim]({removed})[/dim]")
86
+ else:
87
+ console.print("[dim]No link to remove.[/dim]")
@@ -0,0 +1,44 @@
1
+ """Logs commands: view service logs."""
2
+ import click
3
+ from rich.console import Console
4
+
5
+ from ..client import get_client
6
+ from .. import config, context, output
7
+
8
+ console = Console()
9
+
10
+
11
+ @click.command()
12
+ @click.argument("project_id", required=False, default=None)
13
+ @click.argument("service_id", required=False, default=None)
14
+ @click.option("--tail", "-n", default=100, type=int, help="Number of log lines")
15
+ @click.option("--follow", "-f", is_flag=True, help="Stream new logs live (Ctrl-C to stop)")
16
+ def logs(project_id, service_id, tail, follow):
17
+ """View logs for a service. Uses the linked project/service if omitted."""
18
+ client = get_client()
19
+ link = context.read_link()
20
+ project_id = context.resolve_project(client, project_id, link)
21
+ service_id = context.resolve_service(client, project_id, service_id, link)
22
+
23
+ if follow:
24
+ from ..log_stream import follow_logs
25
+ token = config.get_tokens()[0]
26
+ raise SystemExit(follow_logs(config.get_api_url(), token, service_id))
27
+
28
+ resp = client.get(f"/projects/{project_id}/services/{service_id}/logs/", params={"tail": tail})
29
+ if resp.status_code != 200:
30
+ console.print("[red]Failed to fetch logs.[/red]")
31
+ raise SystemExit(1)
32
+
33
+ data = resp.json()
34
+ if output.json_enabled():
35
+ output.emit(data)
36
+ return
37
+ log_text = data.get("logs", "")
38
+ if not log_text:
39
+ console.print("[dim]No logs available.[/dim]")
40
+ return
41
+
42
+ # Print logs line by line
43
+ for line in log_text.splitlines():
44
+ console.print(line)
@@ -0,0 +1,50 @@
1
+ """Service metrics command — current CPU / memory / network snapshot."""
2
+ import click
3
+ from rich.console import Console
4
+
5
+ from ..client import get_client
6
+ from .. import context, output
7
+
8
+ console = Console()
9
+
10
+
11
+ @click.command()
12
+ @click.option("--service", "-s", default=None, help="Service id or name (else linked)")
13
+ def metrics(service):
14
+ """Show the latest resource-usage snapshot for a service."""
15
+ client = get_client()
16
+ link = context.read_link()
17
+ service_id = context.resolve_service(client, link.get("project_id"), service, link)
18
+ resp = client.get(f"/services/{service_id}/metrics/current/")
19
+ if resp.status_code != 200:
20
+ console.print(f"[red]Failed to fetch metrics: {resp.text}[/red]")
21
+ raise SystemExit(1)
22
+
23
+ snap = resp.json()
24
+ if output.json_enabled():
25
+ output.emit(snap)
26
+ return
27
+ if not snap:
28
+ console.print("[dim]No metrics recorded yet.[/dim]")
29
+ return
30
+
31
+ cpu = snap.get("cpu_percent")
32
+ used = snap.get("memory_used_mb")
33
+ limit = snap.get("memory_limit_mb")
34
+ console.print(f"[bold]Service {service_id}[/bold] [dim]({snap.get('recorded_at', '')})[/dim]")
35
+ console.print(f" CPU: {cpu if cpu is not None else '—'}%")
36
+ console.print(f" Memory: {used if used is not None else '—'} / {limit if limit is not None else '—'} MB")
37
+ console.print(f" Network: ↓ {_h(snap.get('network_rx_bytes'))} ↑ {_h(snap.get('network_tx_bytes'))}")
38
+ console.print(f" Replicas: {snap.get('replica_count', '—')}")
39
+
40
+
41
+ def _h(n) -> str:
42
+ """Human-readable bytes."""
43
+ if n is None:
44
+ return "—"
45
+ value = float(n)
46
+ for unit in ("B", "KB", "MB", "GB", "TB"):
47
+ if value < 1024 or unit == "TB":
48
+ return f"{value:.1f}{unit}"
49
+ value /= 1024
50
+ return f"{value:.1f}TB"