scc-cli 1.4.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.
Potentially problematic release.
This version of scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +259 -0
- scc_cli/cli_admin.py +683 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1400 -0
- scc_cli/cli_org.py +1433 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +858 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +603 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1082 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/exit_codes.py +55 -0
- scc_cli/git.py +1405 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +238 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +502 -0
- scc_cli/marketplace/sync.py +257 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +1034 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +582 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +339 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +669 -0
- scc_cli/ui/dashboard/loaders.py +369 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +337 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +521 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +490 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.0.dist-info/METADATA +369 -0
- scc_cli-1.4.0.dist-info/RECORD +112 -0
- scc_cli-1.4.0.dist-info/WHEEL +4 -0
- scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
- scc_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
scc_cli/ui/formatters.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Display formatting helpers for domain types.
|
|
2
|
+
|
|
3
|
+
This module provides pure functions to convert domain objects into display
|
|
4
|
+
representations suitable for the interactive UI. Each formatter transforms
|
|
5
|
+
a domain type into a ListItem for use in pickers and lists.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from scc_cli.docker.core import ContainerInfo
|
|
9
|
+
>>> from scc_cli.ui.formatters import format_container
|
|
10
|
+
>>>
|
|
11
|
+
>>> container = ContainerInfo(id="abc123", name="scc-main", status="Up 2 hours")
|
|
12
|
+
>>> item = format_container(container)
|
|
13
|
+
>>> print(item.label) # scc-main
|
|
14
|
+
>>> print(item.description) # Up 2 hours
|
|
15
|
+
|
|
16
|
+
The formatters follow a consistent pattern:
|
|
17
|
+
- Input: Domain type (dataclass or dict)
|
|
18
|
+
- Output: ListItem with label, description, metadata, and optional governance status
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from typing import TYPE_CHECKING, Any, TypedDict
|
|
25
|
+
|
|
26
|
+
from ..docker.core import ContainerInfo
|
|
27
|
+
from ..git import WorktreeInfo
|
|
28
|
+
from ..theme import Indicators
|
|
29
|
+
from .list_screen import ListItem
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from ..contexts import WorkContext
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
36
|
+
# TypedDict Metadata Definitions (enables mypy type checking and IDE autocomplete)
|
|
37
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ContainerMetadata(TypedDict):
|
|
41
|
+
"""Metadata for container list items.
|
|
42
|
+
|
|
43
|
+
Keys:
|
|
44
|
+
running: "yes" or "no" indicating container state.
|
|
45
|
+
id: Short (12-char) container ID for display.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
running: str
|
|
49
|
+
id: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WorktreeMetadata(TypedDict):
|
|
53
|
+
"""Metadata for worktree list items.
|
|
54
|
+
|
|
55
|
+
Keys:
|
|
56
|
+
path: Full filesystem path to the worktree.
|
|
57
|
+
current: "yes" or "no" indicating if this is the current worktree.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
path: str
|
|
61
|
+
current: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ContextMetadata(TypedDict):
|
|
65
|
+
"""Metadata for work context list items.
|
|
66
|
+
|
|
67
|
+
Keys:
|
|
68
|
+
team: Team/profile name.
|
|
69
|
+
repo: Repository name.
|
|
70
|
+
worktree: Worktree directory name.
|
|
71
|
+
path: Full filesystem path.
|
|
72
|
+
pinned: "yes" or "no".
|
|
73
|
+
running: "yes", "no", or "" (unknown).
|
|
74
|
+
current_branch: "yes", "no", or "" (unknown).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
team: str
|
|
78
|
+
repo: str
|
|
79
|
+
worktree: str
|
|
80
|
+
path: str
|
|
81
|
+
pinned: str
|
|
82
|
+
running: str
|
|
83
|
+
current_branch: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def format_team(
|
|
87
|
+
team: dict[str, Any], *, current_team: str | None = None
|
|
88
|
+
) -> ListItem[dict[str, Any]]:
|
|
89
|
+
"""Format a team dict for display in a picker.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
team: Team dictionary with name and optional metadata.
|
|
93
|
+
current_team: Currently selected team name (marked with indicator).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
ListItem suitable for ListScreen display.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
>>> team = {"name": "platform", "description": "Platform team"}
|
|
100
|
+
>>> item = format_team(team, current_team="platform")
|
|
101
|
+
>>> item.label
|
|
102
|
+
'✓ platform'
|
|
103
|
+
"""
|
|
104
|
+
name = team.get("name", "unknown")
|
|
105
|
+
description = team.get("description", "")
|
|
106
|
+
is_current = current_team is not None and name == current_team
|
|
107
|
+
|
|
108
|
+
# Build label with current indicator
|
|
109
|
+
label = f"{Indicators.get('PASS')} {name}" if is_current else name
|
|
110
|
+
|
|
111
|
+
# Check for credential/governance status
|
|
112
|
+
governance_status: str | None = None
|
|
113
|
+
credential_status = team.get("credential_status")
|
|
114
|
+
if credential_status == "expired":
|
|
115
|
+
governance_status = "blocked"
|
|
116
|
+
elif credential_status == "expiring":
|
|
117
|
+
governance_status = "warning"
|
|
118
|
+
|
|
119
|
+
# Build description parts
|
|
120
|
+
desc_parts: list[str] = []
|
|
121
|
+
if description:
|
|
122
|
+
desc_parts.append(description)
|
|
123
|
+
if credential_status == "expired":
|
|
124
|
+
desc_parts.append("(credentials expired)")
|
|
125
|
+
elif credential_status == "expiring":
|
|
126
|
+
desc_parts.append("(credentials expiring)")
|
|
127
|
+
|
|
128
|
+
return ListItem(
|
|
129
|
+
value=team,
|
|
130
|
+
label=label,
|
|
131
|
+
description=" ".join(desc_parts),
|
|
132
|
+
governance_status=governance_status,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def format_container(container: ContainerInfo) -> ListItem[ContainerInfo]:
|
|
137
|
+
"""Format a container for display in a picker or list.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
container: Container information from Docker.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
ListItem suitable for ListScreen display.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
>>> container = ContainerInfo(
|
|
147
|
+
... id="abc123",
|
|
148
|
+
... name="scc-main",
|
|
149
|
+
... status="Up 2 hours",
|
|
150
|
+
... profile="team-a",
|
|
151
|
+
... workspace="/home/user/project",
|
|
152
|
+
... )
|
|
153
|
+
>>> item = format_container(container)
|
|
154
|
+
>>> item.label
|
|
155
|
+
'scc-main'
|
|
156
|
+
"""
|
|
157
|
+
# Build description parts
|
|
158
|
+
desc_parts: list[str] = []
|
|
159
|
+
|
|
160
|
+
if container.profile:
|
|
161
|
+
desc_parts.append(container.profile)
|
|
162
|
+
|
|
163
|
+
if container.workspace:
|
|
164
|
+
# Show just the workspace name (last path component)
|
|
165
|
+
workspace_name = container.workspace.split("/")[-1]
|
|
166
|
+
desc_parts.append(workspace_name)
|
|
167
|
+
|
|
168
|
+
if container.status:
|
|
169
|
+
# Simplify status (e.g., "Up 2 hours" -> "Up 2h")
|
|
170
|
+
status_short = _shorten_docker_status(container.status)
|
|
171
|
+
desc_parts.append(status_short)
|
|
172
|
+
|
|
173
|
+
# Determine if container is running
|
|
174
|
+
is_running = container.status.startswith("Up") if container.status else False
|
|
175
|
+
|
|
176
|
+
return ListItem(
|
|
177
|
+
value=container,
|
|
178
|
+
label=container.name,
|
|
179
|
+
description=" ".join(desc_parts),
|
|
180
|
+
metadata={
|
|
181
|
+
"running": "yes" if is_running else "no",
|
|
182
|
+
"id": container.id[:12], # Short container ID
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def format_session(session: dict[str, Any]) -> ListItem[dict[str, Any]]:
|
|
188
|
+
"""Format a session dict for display in a picker.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
session: Session dictionary with name, team, branch, etc.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
ListItem suitable for ListScreen display.
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
>>> session = {
|
|
198
|
+
... "name": "project-feature",
|
|
199
|
+
... "team": "platform",
|
|
200
|
+
... "branch": "feature/auth",
|
|
201
|
+
... "last_used": "2 hours ago",
|
|
202
|
+
... }
|
|
203
|
+
>>> item = format_session(session)
|
|
204
|
+
>>> item.label
|
|
205
|
+
'project-feature'
|
|
206
|
+
"""
|
|
207
|
+
name = session.get("name", "Unnamed")
|
|
208
|
+
|
|
209
|
+
# Build description parts
|
|
210
|
+
desc_parts: list[str] = []
|
|
211
|
+
|
|
212
|
+
if session.get("team"):
|
|
213
|
+
desc_parts.append(str(session["team"]))
|
|
214
|
+
|
|
215
|
+
if session.get("branch"):
|
|
216
|
+
desc_parts.append(str(session["branch"]))
|
|
217
|
+
|
|
218
|
+
if session.get("last_used"):
|
|
219
|
+
desc_parts.append(str(session["last_used"]))
|
|
220
|
+
|
|
221
|
+
# Check for governance warnings (e.g., expiring exceptions)
|
|
222
|
+
governance_status: str | None = None
|
|
223
|
+
if session.get("has_exception_warning"):
|
|
224
|
+
governance_status = "warning"
|
|
225
|
+
|
|
226
|
+
return ListItem(
|
|
227
|
+
value=session,
|
|
228
|
+
label=name,
|
|
229
|
+
description=" ".join(desc_parts),
|
|
230
|
+
governance_status=governance_status,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def format_worktree(worktree: WorktreeInfo) -> ListItem[WorktreeInfo]:
|
|
235
|
+
"""Format a worktree for display in a picker or list.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
worktree: Worktree information from Git.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
ListItem suitable for ListScreen display.
|
|
242
|
+
|
|
243
|
+
Example:
|
|
244
|
+
>>> from scc_cli.git import WorktreeInfo
|
|
245
|
+
>>> wt = WorktreeInfo(
|
|
246
|
+
... path="/home/user/project-feature",
|
|
247
|
+
... branch="feature/auth",
|
|
248
|
+
... is_current=True,
|
|
249
|
+
... has_changes=True,
|
|
250
|
+
... )
|
|
251
|
+
>>> item = format_worktree(wt)
|
|
252
|
+
>>> item.label
|
|
253
|
+
'✓ project-feature'
|
|
254
|
+
"""
|
|
255
|
+
from pathlib import Path
|
|
256
|
+
|
|
257
|
+
# Use just the directory name for the label
|
|
258
|
+
dir_name = Path(worktree.path).name
|
|
259
|
+
|
|
260
|
+
# Build label with current indicator
|
|
261
|
+
label = f"{Indicators.get('PASS')} {dir_name}" if worktree.is_current else dir_name
|
|
262
|
+
|
|
263
|
+
# Build description parts
|
|
264
|
+
desc_parts: list[str] = []
|
|
265
|
+
|
|
266
|
+
if worktree.branch:
|
|
267
|
+
desc_parts.append(worktree.branch)
|
|
268
|
+
|
|
269
|
+
if worktree.has_changes:
|
|
270
|
+
desc_parts.append("*modified")
|
|
271
|
+
|
|
272
|
+
if worktree.is_current:
|
|
273
|
+
desc_parts.append("(current)")
|
|
274
|
+
|
|
275
|
+
return ListItem(
|
|
276
|
+
value=worktree,
|
|
277
|
+
label=label,
|
|
278
|
+
description=" ".join(desc_parts),
|
|
279
|
+
metadata={
|
|
280
|
+
"path": worktree.path,
|
|
281
|
+
"current": "yes" if worktree.is_current else "no",
|
|
282
|
+
},
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def format_context(
|
|
287
|
+
context: WorkContext,
|
|
288
|
+
*,
|
|
289
|
+
is_running: bool | None = None,
|
|
290
|
+
is_current_branch: bool | None = None,
|
|
291
|
+
) -> ListItem[WorkContext]:
|
|
292
|
+
"""Format a work context for display in a picker.
|
|
293
|
+
|
|
294
|
+
Shows the context's display_label (team · repo · worktree) with
|
|
295
|
+
pinned indicator, status indicator, current branch indicator, and
|
|
296
|
+
relative time since last used.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
context: Work context to format.
|
|
300
|
+
is_running: Whether the context's container is running.
|
|
301
|
+
True = show 🟢 (running), False = show ⚫ (stopped), None = no indicator.
|
|
302
|
+
is_current_branch: Whether this context matches the current git branch.
|
|
303
|
+
True = show ★ indicator, False/None = no indicator.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
ListItem suitable for ListScreen display.
|
|
307
|
+
|
|
308
|
+
Example:
|
|
309
|
+
>>> from scc_cli.contexts import WorkContext
|
|
310
|
+
>>> from pathlib import Path
|
|
311
|
+
>>> ctx = WorkContext(
|
|
312
|
+
... team="platform",
|
|
313
|
+
... repo_root=Path("/code/api"),
|
|
314
|
+
... worktree_path=Path("/code/api"),
|
|
315
|
+
... worktree_name="main",
|
|
316
|
+
... pinned=True,
|
|
317
|
+
... )
|
|
318
|
+
>>> item = format_context(ctx)
|
|
319
|
+
>>> item.label
|
|
320
|
+
'📌 platform · api · main'
|
|
321
|
+
>>> item = format_context(ctx, is_running=True)
|
|
322
|
+
>>> '🟢' in item.label
|
|
323
|
+
True
|
|
324
|
+
>>> item = format_context(ctx, is_current_branch=True)
|
|
325
|
+
>>> '★' in item.label
|
|
326
|
+
True
|
|
327
|
+
"""
|
|
328
|
+
# Build label parts
|
|
329
|
+
parts: list[str] = []
|
|
330
|
+
|
|
331
|
+
# Add pinned indicator
|
|
332
|
+
if context.pinned:
|
|
333
|
+
parts.append("📌")
|
|
334
|
+
|
|
335
|
+
# Add current branch indicator (matches CWD branch)
|
|
336
|
+
if is_current_branch is True:
|
|
337
|
+
parts.append("★")
|
|
338
|
+
|
|
339
|
+
# Add status indicator (running/stopped)
|
|
340
|
+
if is_running is True:
|
|
341
|
+
parts.append("🟢")
|
|
342
|
+
elif is_running is False:
|
|
343
|
+
parts.append("⚫")
|
|
344
|
+
|
|
345
|
+
# Add display label
|
|
346
|
+
parts.append(context.display_label)
|
|
347
|
+
|
|
348
|
+
label = " ".join(parts)
|
|
349
|
+
|
|
350
|
+
# Build description parts
|
|
351
|
+
desc_parts: list[str] = []
|
|
352
|
+
|
|
353
|
+
# Add relative time since last used
|
|
354
|
+
relative_time = _format_relative_time(context.last_used)
|
|
355
|
+
if relative_time:
|
|
356
|
+
desc_parts.append(relative_time)
|
|
357
|
+
|
|
358
|
+
# Add session info if available
|
|
359
|
+
if context.last_session_id:
|
|
360
|
+
desc_parts.append(f"session: {context.last_session_id}")
|
|
361
|
+
|
|
362
|
+
return ListItem(
|
|
363
|
+
value=context,
|
|
364
|
+
label=label,
|
|
365
|
+
description=" ".join(desc_parts),
|
|
366
|
+
metadata={
|
|
367
|
+
"team": context.team or "", # Empty string for standalone mode (no team)
|
|
368
|
+
"repo": context.repo_name,
|
|
369
|
+
"worktree": context.worktree_name,
|
|
370
|
+
"path": str(context.worktree_path),
|
|
371
|
+
"pinned": "yes" if context.pinned else "no",
|
|
372
|
+
"running": "yes" if is_running else "no" if is_running is False else "",
|
|
373
|
+
"current_branch": (
|
|
374
|
+
"yes" if is_current_branch else "no" if is_current_branch is False else ""
|
|
375
|
+
),
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _format_relative_time(iso_timestamp: str) -> str:
|
|
381
|
+
"""Format an ISO timestamp as relative time (e.g., '2 hours ago').
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
iso_timestamp: ISO 8601 timestamp string.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Human-readable relative time string, or empty if parsing fails.
|
|
388
|
+
"""
|
|
389
|
+
try:
|
|
390
|
+
# Parse ISO format, handling Z suffix
|
|
391
|
+
timestamp = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00"))
|
|
392
|
+
now = datetime.now(timezone.utc)
|
|
393
|
+
delta = now - timestamp
|
|
394
|
+
|
|
395
|
+
seconds = int(delta.total_seconds())
|
|
396
|
+
if seconds < 0:
|
|
397
|
+
return ""
|
|
398
|
+
if seconds < 60:
|
|
399
|
+
return "just now"
|
|
400
|
+
if seconds < 3600:
|
|
401
|
+
minutes = seconds // 60
|
|
402
|
+
return f"{minutes}m ago"
|
|
403
|
+
if seconds < 86400:
|
|
404
|
+
hours = seconds // 3600
|
|
405
|
+
return f"{hours}h ago"
|
|
406
|
+
if seconds < 604800:
|
|
407
|
+
days = seconds // 86400
|
|
408
|
+
return f"{days}d ago"
|
|
409
|
+
weeks = seconds // 604800
|
|
410
|
+
return f"{weeks}w ago"
|
|
411
|
+
except (ValueError, TypeError):
|
|
412
|
+
return ""
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _shorten_docker_status(status: str) -> str:
|
|
416
|
+
"""Shorten Docker status strings for compact display.
|
|
417
|
+
|
|
418
|
+
Converts verbose time units to abbreviations:
|
|
419
|
+
- "Up 2 hours" -> "Up 2h"
|
|
420
|
+
- "Exited (0) 5 minutes ago" -> "Exited 5m ago"
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
status: Full Docker status string.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Shortened status string.
|
|
427
|
+
"""
|
|
428
|
+
result = status
|
|
429
|
+
replacements = [
|
|
430
|
+
(" hours", "h"),
|
|
431
|
+
(" hour", "h"),
|
|
432
|
+
(" minutes", "m"),
|
|
433
|
+
(" minute", "m"),
|
|
434
|
+
(" seconds", "s"),
|
|
435
|
+
(" second", "s"),
|
|
436
|
+
(" days", "d"),
|
|
437
|
+
(" day", "d"),
|
|
438
|
+
(" weeks", "w"),
|
|
439
|
+
(" week", "w"),
|
|
440
|
+
]
|
|
441
|
+
for old, new in replacements:
|
|
442
|
+
result = result.replace(old, new)
|
|
443
|
+
return result
|