docker-orb 1.0.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.
docker_orb/compose.py ADDED
@@ -0,0 +1,391 @@
1
+ """
2
+ Docker Compose abstraction layer.
3
+
4
+ All interaction goes through this module via subprocess calls to the user's
5
+ existing `docker` CLI (Compose v2, i.e. `docker compose ...`) — inheriting
6
+ their Docker context/auth. No Python Docker SDK dependency, same philosophy
7
+ as kube-orb's kubectl.py: shell out, parse JSON, keep the rest of the app
8
+ ignorant of the underlying tool.
9
+
10
+ Log streaming uses `docker logs -f --timestamps <container>` per container
11
+ rather than a single multiplexed `docker compose logs -f`. Functionally this
12
+ is the same data `docker compose logs` streams (compose containers are
13
+ ordinary Docker containers carrying `com.docker.compose.*` labels) but
14
+ keeping one subprocess per container mirrors kube-orb's per-pod architecture
15
+ exactly, so the merge/color/filter machinery in viewer/ needed no changes.
16
+
17
+ NOTE: --since duration semantics below are carried over from kube-orb's
18
+ kubectl quirk-handling as a defensive default. Docker's own duration parsing
19
+ for `docker logs --since` has not been verified against a live daemon in
20
+ this environment — worth a smoke test against a real `docker compose`
21
+ project before relying on it in production.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import json
27
+ import re
28
+ import subprocess
29
+ from collections.abc import AsyncIterator
30
+ from datetime import datetime
31
+
32
+ from .models import Service, LogLine, Container, ContainerStatus
33
+
34
+
35
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
36
+
37
+ def _run(args: list[str], check: bool = True) -> str:
38
+ """Run a `docker` command, return stdout as str. Raises on non-zero exit."""
39
+ result = subprocess.run(
40
+ ["docker", *args],
41
+ capture_output=True,
42
+ text=True,
43
+ check=check,
44
+ )
45
+ return result.stdout.strip()
46
+
47
+
48
+ def _run_json_lines(args: list[str]) -> list[dict]:
49
+ """
50
+ Run a docker/compose command that emits `--format json` output and parse
51
+ it. Compose has shipped two shapes for this over the years: a single
52
+ JSON array, or newline-delimited JSON objects (one per line). Handle
53
+ both rather than pinning to one Compose version.
54
+ """
55
+ out = _run(args)
56
+ if not out:
57
+ return []
58
+ try:
59
+ parsed = json.loads(out)
60
+ return parsed if isinstance(parsed, list) else [parsed]
61
+ except json.JSONDecodeError:
62
+ pass
63
+ items = []
64
+ for line in out.splitlines():
65
+ line = line.strip()
66
+ if not line:
67
+ continue
68
+ items.append(json.loads(line))
69
+ return items
70
+
71
+
72
+ # docker logs --timestamps prefixes each line with an RFC3339Nano timestamp
73
+ # (up to 9 fractional-second digits) and a space, e.g.
74
+ # "2026-07-08T12:34:56.789012345Z log line content here".
75
+ _TIMESTAMP_RE = re.compile(
76
+ r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2}) "
77
+ )
78
+
79
+
80
+ def _split_timestamp(raw: str) -> tuple[datetime | None, str]:
81
+ """
82
+ Split a `docker logs --timestamps` line into (parsed timestamp, content).
83
+ Returns (None, raw) unchanged if the line doesn't start with a
84
+ recognizable timestamp (defensive — should always match in practice).
85
+ """
86
+ m = _TIMESTAMP_RE.match(raw)
87
+ if not m:
88
+ return None, raw
89
+ base, frac, tz = m.groups()
90
+ # datetime.fromisoformat only accepts up to microsecond (6-digit)
91
+ # precision on Python 3.10/3.11; truncate rather than pad-and-fail.
92
+ frac = (frac or "").ljust(6, "0")[:6]
93
+ tz = "+00:00" if tz == "Z" else tz
94
+ try:
95
+ timestamp = datetime.fromisoformat(f"{base}.{frac}{tz}")
96
+ except ValueError:
97
+ return None, raw
98
+ return timestamp, raw[m.end():]
99
+
100
+
101
+ # A --since value that is empty/zero in every unit ("0", "0s", "0h", ...).
102
+ _ZERO_SINCE_RE = re.compile(r"^0+(?:\.0+)?(ns|us|µs|ms|s|m|h)?$")
103
+
104
+
105
+ def _normalize_since(since: str | None) -> str:
106
+ """
107
+ Without a meaningful --since bound, `docker logs -f` dumps a container's
108
+ entire buffered history before it starts following. For a live stream
109
+ that means replaying everything the container has ever logged instead
110
+ of only new lines. Substitute a tiny positive duration for None/empty/
111
+ zero so the default (and a user typing a literal "0") gets "only new
112
+ lines" behavior instead of an unbounded history dump — same defensive
113
+ trick kube-orb uses for kubectl.
114
+ """
115
+ if not since or _ZERO_SINCE_RE.match(since.strip()):
116
+ return "1s"
117
+ return since
118
+
119
+
120
+ def wants_backfill(since: str | None) -> bool:
121
+ """
122
+ True if `since` represents a genuine, non-trivial look-back window —
123
+ i.e. whether the caller should expect (and handle) a historical
124
+ backfill burst. Mirrors _normalize_since()'s zero-detection.
125
+ """
126
+ return bool(since) and not _ZERO_SINCE_RE.match(since.strip())
127
+
128
+
129
+ # ─── Context / project ───────────────────────────────────────────────────────
130
+
131
+ def get_current_project() -> str:
132
+ """
133
+ Return the Compose project name for the current working directory, the
134
+ way `docker compose` itself would resolve it (COMPOSE_PROJECT_NAME env
135
+ var, `name:` in the compose file, or the containing directory name).
136
+ Falls back to the first running project, then 'default', if the cwd
137
+ isn't a compose project.
138
+ """
139
+ try:
140
+ data = json.loads(_run(["compose", "config", "--format", "json"]))
141
+ name = data.get("name")
142
+ if name:
143
+ return name
144
+ except (subprocess.CalledProcessError, json.JSONDecodeError):
145
+ pass
146
+ projects = get_projects()
147
+ return projects[0] if projects else "default"
148
+
149
+
150
+ def get_projects() -> list[str]:
151
+ """Return names of all Compose projects Docker currently knows about."""
152
+ items = _run_json_lines(["compose", "ls", "--all", "--format", "json"])
153
+ return [item["Name"] for item in items if item.get("Name")]
154
+
155
+
156
+ # ─── Services / containers ────────────────────────────────────────────────────
157
+
158
+ def _list_containers(project: str) -> list[dict]:
159
+ """Raw `docker compose ps` rows for every container in a project."""
160
+ return _run_json_lines([
161
+ "compose", "-p", project, "ps", "--all", "--format", "json",
162
+ ])
163
+
164
+
165
+ def get_services(project: str) -> list[Service]:
166
+ """
167
+ Return services in the project, each with the count of containers
168
+ currently backing it (a service may have 0 if it isn't running, or
169
+ more than 1 if scaled).
170
+ """
171
+ rows = _list_containers(project)
172
+ counts: dict[str, int] = {}
173
+ for row in rows:
174
+ name = row.get("Service")
175
+ if name:
176
+ counts[name] = counts.get(name, 0) + 1
177
+
178
+ # Include services defined in the compose file even if currently
179
+ # stopped (0 containers), so they're still selectable in the wizard.
180
+ try:
181
+ defined = json.loads(_run(["compose", "-p", project, "config", "--format", "json"]))
182
+ for name in defined.get("services", {}):
183
+ counts.setdefault(name, 0)
184
+ except (subprocess.CalledProcessError, json.JSONDecodeError):
185
+ pass
186
+
187
+ services = [
188
+ Service(name=name, project=project, container_count=count)
189
+ for name, count in counts.items()
190
+ ]
191
+ return sorted(services, key=lambda s: s.name)
192
+
193
+
194
+ def get_containers_for_service(project: str, service: Service) -> list[Container]:
195
+ """Return containers currently backing a single service."""
196
+ return get_containers_for_services(project, [service])
197
+
198
+
199
+ def get_containers_for_services(project: str, services: list[Service]) -> list[Container]:
200
+ """Return all containers across a list of services."""
201
+ if not services:
202
+ return []
203
+ wanted = {s.name for s in services}
204
+ containers: list[Container] = []
205
+ for row in _list_containers(project):
206
+ service_name = row.get("Service")
207
+ if service_name not in wanted:
208
+ continue
209
+ containers.append(Container(
210
+ name=row.get("Name", ""),
211
+ project=project,
212
+ service=service_name,
213
+ phase=_state_to_phase(row.get("State", "")),
214
+ restart_count=0, # not exposed by `ps`; see get_container_statuses
215
+ ready=(row.get("State") == "running" and row.get("Health", "") in ("", "healthy")),
216
+ ))
217
+ return containers
218
+
219
+
220
+ def _state_to_phase(state: str) -> str:
221
+ """Map Docker's lowercase container State to a k8s-style Title Case phase."""
222
+ return {
223
+ "running": "Running",
224
+ "exited": "Exited",
225
+ "restarting": "Restarting",
226
+ "paused": "Paused",
227
+ "created": "Created",
228
+ "dead": "Dead",
229
+ "removing": "Removing",
230
+ }.get(state, state.title() or "Unknown")
231
+
232
+
233
+ # ─── Container health polling ─────────────────────────────────────────────────
234
+
235
+ def get_container_statuses(project: str, container_names: list[str]) -> list[ContainerStatus]:
236
+ """
237
+ Poll current health for a specific list of container names via
238
+ `docker inspect` (batched into a single call). Used by the health panel
239
+ on its interval. Unlike `docker compose ps`, inspect exposes the exact
240
+ RestartCount and StartedAt Docker tracks natively per container.
241
+ """
242
+ if not container_names:
243
+ return []
244
+
245
+ try:
246
+ raw = _run(["inspect", *container_names])
247
+ items = json.loads(raw) if raw else []
248
+ except subprocess.CalledProcessError as exc:
249
+ # `docker inspect` still prints JSON for the containers it *did*
250
+ # find on stdout even when one name doesn't exist; fall back to that.
251
+ try:
252
+ items = json.loads(exc.stdout) if exc.stdout else []
253
+ except (json.JSONDecodeError, TypeError):
254
+ return []
255
+
256
+ statuses = []
257
+ for item in items:
258
+ name = item.get("Name", "").lstrip("/")
259
+ state = item.get("State", {})
260
+ phase = _state_to_phase(state.get("Status", ""))
261
+ health = state.get("Health", {}).get("Status")
262
+ ready = state.get("Running", False) and health in (None, "healthy")
263
+ restarts = item.get("RestartCount", 0)
264
+
265
+ started_at = state.get("StartedAt")
266
+ age = 0.0
267
+ if started_at:
268
+ try:
269
+ start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
270
+ age = (datetime.now(start.tzinfo) - start).total_seconds()
271
+ except ValueError:
272
+ pass
273
+
274
+ labels = item.get("Config", {}).get("Labels", {}) or {}
275
+ service = labels.get("com.docker.compose.service", name)
276
+
277
+ statuses.append(ContainerStatus(
278
+ name=name,
279
+ service=service,
280
+ phase=phase,
281
+ restart_count=restarts,
282
+ ready=ready,
283
+ age_seconds=age,
284
+ ))
285
+
286
+ return statuses
287
+
288
+
289
+ # ─── Log streaming ─────────────────────────────────────────────────────────────
290
+
291
+ async def stream_logs(
292
+ container_name: str,
293
+ project: str,
294
+ since: str | None = None,
295
+ tail: int | None = None,
296
+ ) -> AsyncIterator[LogLine]:
297
+ """
298
+ Async generator yielding LogLine objects from a single container.
299
+ Runs `docker logs -f --timestamps <container>` in a subprocess, reads
300
+ lines as they arrive. Caller should run one coroutine per container and
301
+ merge with asyncio — same pattern as kube-orb's per-pod streaming.
302
+
303
+ Requests --timestamps so each LogLine gets a real log_timestamp (see
304
+ models.LogLine) — used to interleave a backfill burst across containers
305
+ correctly. The timestamp is parsed and stripped back out of `content`,
306
+ so displayed/matched text is identical to what --timestamps=false would
307
+ have produced.
308
+ """
309
+ args = ["docker", "logs", "-f", "--timestamps", "--since", _normalize_since(since)]
310
+ if tail is not None:
311
+ args += ["--tail", str(tail)]
312
+ args.append(container_name)
313
+
314
+ proc = await asyncio.create_subprocess_exec(
315
+ *args,
316
+ stdout=asyncio.subprocess.PIPE,
317
+ stderr=asyncio.subprocess.DEVNULL,
318
+ )
319
+
320
+ assert proc.stdout is not None
321
+ try:
322
+ while True:
323
+ line_bytes = await proc.stdout.readline()
324
+ if not line_bytes:
325
+ break
326
+ raw = line_bytes.decode(errors="replace").rstrip("\n")
327
+ log_time, content = _split_timestamp(raw)
328
+ yield LogLine(
329
+ container_name=container_name,
330
+ content=content,
331
+ log_timestamp=log_time,
332
+ )
333
+ finally:
334
+ try:
335
+ proc.kill()
336
+ except ProcessLookupError:
337
+ pass
338
+ await proc.wait()
339
+
340
+
341
+ async def dump_logs(
342
+ container_name: str,
343
+ project: str,
344
+ since: str | None = None,
345
+ tail: int | None = None,
346
+ ) -> list[LogLine]:
347
+ """
348
+ Fetch a bounded set of log lines from a container (no follow).
349
+ Used in dump mode.
350
+ """
351
+ args = ["docker", "logs", "--timestamps=false"]
352
+ if since:
353
+ args += ["--since", since]
354
+ if tail is not None:
355
+ args += ["--tail", str(tail)]
356
+ args.append(container_name)
357
+
358
+ proc = await asyncio.create_subprocess_exec(
359
+ *args,
360
+ stdout=asyncio.subprocess.PIPE,
361
+ stderr=asyncio.subprocess.DEVNULL,
362
+ )
363
+ stdout, _ = await proc.communicate()
364
+ lines = stdout.decode(errors="replace").splitlines()
365
+ return [LogLine(container_name=container_name, content=line) for line in lines if line]
366
+
367
+
368
+ # ─── Container restart actions ─────────────────────────────────────────────────
369
+
370
+ def restart_container(container_name: str, project: str) -> bool:
371
+ """
372
+ Restart a single container in place (same container, same image —
373
+ Compose will not recreate it). Returns True on success.
374
+ """
375
+ try:
376
+ _run(["restart", container_name])
377
+ return True
378
+ except subprocess.CalledProcessError:
379
+ return False
380
+
381
+
382
+ def restart_service(service_name: str, project: str) -> bool:
383
+ """
384
+ Recreate every container backing a service (picks up a new image/env if
385
+ one is available, unlike restart_container). Returns True on success.
386
+ """
387
+ try:
388
+ _run(["compose", "-p", project, "up", "-d", "--force-recreate", "--no-deps", service_name])
389
+ return True
390
+ except subprocess.CalledProcessError:
391
+ return False
docker_orb/config.py ADDED
@@ -0,0 +1,244 @@
1
+ """
2
+ Config management for docker-orb.
3
+
4
+ Storage layout under ~/.config/docker-orb/:
5
+ strings.yaml — global saved string lists (filters/highlights/monitors)
6
+ projects/<proj>.yaml — per-project saved session configs
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+
15
+ from .models import HealthConfig, LogMode, SavedStrings, SessionConfig
16
+
17
+ CONFIG_DIR = Path.home() / ".config" / "docker-orb"
18
+ STRINGS_FILE = CONFIG_DIR / "strings.yaml"
19
+ PROJECTS_DIR = CONFIG_DIR / "projects"
20
+
21
+
22
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
23
+
24
+ def _ensure_dirs() -> None:
25
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
26
+ PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
27
+
28
+
29
+ def _load_yaml(path: Path) -> dict:
30
+ if not path.exists():
31
+ return {}
32
+ with path.open() as f:
33
+ return yaml.safe_load(f) or {}
34
+
35
+
36
+ def _normalize_string_list(value: object) -> list[str]:
37
+ if not isinstance(value, list):
38
+ return []
39
+ normalized: list[str] = []
40
+ for item in value:
41
+ if isinstance(item, str):
42
+ normalized.append(item)
43
+ elif isinstance(item, list):
44
+ # A hand-edited strings.yaml with an unquoted bracketed literal
45
+ # like "[debug]" parses as YAML flow-sequence syntax (a nested
46
+ # list) instead of the string the user meant. Reconstruct the
47
+ # original bracketed text rather than losing the pattern or
48
+ # emitting Python's list repr (e.g. "['debug']").
49
+ normalized.append(f"[{', '.join(str(x) for x in item)}]")
50
+ elif item is not None:
51
+ normalized.append(str(item))
52
+ return normalized
53
+
54
+
55
+ def _save_yaml(path: Path, data: dict) -> None:
56
+ _ensure_dirs()
57
+ with path.open("w") as f:
58
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
59
+
60
+
61
+ # ─── String parsing ───────────────────────────────────────────────────────────
62
+
63
+ def parse_string_input(raw: str) -> list[str]:
64
+ """
65
+ Parse a comma-separated input string into individual match patterns.
66
+ Quoted tokens (for strings containing commas) are handled.
67
+ Each token is stripped of whitespace.
68
+
69
+ Examples:
70
+ 'ERROR, timeout' → ['ERROR', 'timeout']
71
+ '/5[0-9]{2}/, WARN' → ['/5[0-9]{2}/', 'WARN']
72
+ '"GET /api, POST /api"' → ['GET /api, POST /api']
73
+ """
74
+ tokens: list[str] = []
75
+ current = ""
76
+ in_quote = False
77
+
78
+ for char in raw:
79
+ if char == '"':
80
+ in_quote = not in_quote
81
+ elif char == "," and not in_quote:
82
+ token = current.strip()
83
+ if token:
84
+ tokens.append(token)
85
+ current = ""
86
+ else:
87
+ current += char
88
+
89
+ token = current.strip()
90
+ if token:
91
+ tokens.append(token)
92
+
93
+ return tokens
94
+
95
+
96
+ def is_regex_pattern(s: str) -> bool:
97
+ """Return True if the string uses /pattern/ regex syntax."""
98
+ return s.startswith("/") and s.endswith("/") and len(s) > 2
99
+
100
+
101
+ def compile_pattern(s: str, ignore_case: bool = False) -> re.Pattern:
102
+ """
103
+ Compile a match string into a regex Pattern.
104
+ /pattern/ strings are compiled as regex.
105
+ Plain strings are compiled as literal (re.escape).
106
+ """
107
+ flags = re.IGNORECASE if ignore_case else 0
108
+ if is_regex_pattern(s):
109
+ return re.compile(s[1:-1], flags)
110
+ return re.compile(re.escape(s), flags)
111
+
112
+
113
+ def matches(line: str, patterns: list[re.Pattern]) -> bool:
114
+ """Return True if line matches any of the compiled patterns."""
115
+ return any(p.search(line) for p in patterns)
116
+
117
+
118
+ def compile_patterns(strings: list[str], ignore_case: bool = False) -> list[re.Pattern]:
119
+ return [compile_pattern(s, ignore_case) for s in strings]
120
+
121
+
122
+ # ─── Saved strings ────────────────────────────────────────────────────────────
123
+
124
+ def load_saved_strings() -> SavedStrings:
125
+ data = _load_yaml(STRINGS_FILE)
126
+ return SavedStrings(
127
+ filters=_normalize_string_list(data.get("filters", [])),
128
+ highlights=_normalize_string_list(data.get("highlights", [])),
129
+ monitors=_normalize_string_list(data.get("monitors", [])),
130
+ )
131
+
132
+
133
+ def save_saved_strings(strings: SavedStrings) -> None:
134
+ _save_yaml(STRINGS_FILE, {
135
+ "filters": strings.filters,
136
+ "highlights": strings.highlights,
137
+ "monitors": strings.monitors,
138
+ })
139
+
140
+
141
+ def add_to_saved_strings(
142
+ category: str,
143
+ new_strings: list[str],
144
+ ) -> None:
145
+ """
146
+ Append new_strings to the given category ('filters'|'highlights'|'monitors')
147
+ in the global strings file, deduplicating.
148
+ """
149
+ saved = load_saved_strings()
150
+ existing = getattr(saved, category)
151
+ merged = existing + [s for s in new_strings if s not in existing]
152
+ setattr(saved, category, merged)
153
+ save_saved_strings(saved)
154
+
155
+
156
+ # ─── Session configs ──────────────────────────────────────────────────────────
157
+
158
+ def _ns_config_path(project: str, name: str) -> Path:
159
+ safe_name = re.sub(r"[^\w\-]", "_", name)
160
+ return PROJECTS_DIR / project / f"{safe_name}.yaml"
161
+
162
+
163
+ def list_saved_configs(project: str) -> list[str]:
164
+ """Return names of saved configs for a project."""
165
+ proj_dir = PROJECTS_DIR / project
166
+ if not proj_dir.exists():
167
+ return []
168
+ return [p.stem for p in sorted(proj_dir.glob("*.yaml"))]
169
+
170
+
171
+ def list_all_saved_configs() -> list[tuple[str, str]]:
172
+ """Return (project, name) for every saved config across all projects."""
173
+ if not PROJECTS_DIR.exists():
174
+ return []
175
+ result = []
176
+ for proj_dir in sorted(PROJECTS_DIR.iterdir()):
177
+ if proj_dir.is_dir():
178
+ for p in sorted(proj_dir.glob("*.yaml")):
179
+ result.append((proj_dir.name, p.stem))
180
+ return result
181
+
182
+
183
+ def load_session_config(project: str, name: str) -> SessionConfig | None:
184
+ path = _ns_config_path(project, name)
185
+ data = _load_yaml(path)
186
+ if not data:
187
+ return None
188
+
189
+ health_data = data.get("health", {})
190
+ return SessionConfig(
191
+ project=project,
192
+ services=data.get("services", []),
193
+ mode=LogMode(data.get("mode", "stream")),
194
+ tail=data.get("tail"),
195
+ since=data.get("since"),
196
+ filters=_normalize_string_list(data.get("filters", [])),
197
+ highlights=_normalize_string_list(data.get("highlights", [])),
198
+ monitors=_normalize_string_list(data.get("monitors", [])),
199
+ filters_ignore_case=data.get("filters_ignore_case", False),
200
+ highlights_ignore_case=data.get("highlights_ignore_case", False),
201
+ monitors_ignore_case=data.get("monitors_ignore_case", False),
202
+ line_wrap=data.get("line_wrap", True),
203
+ color_full_line=data.get("color_full_line", False),
204
+ json_format=data.get("json_format", False),
205
+ collapse_repeats=data.get("collapse_repeats", False),
206
+ health=HealthConfig(
207
+ enabled=health_data.get("enabled", False),
208
+ interval_minutes=health_data.get("interval_minutes", 5),
209
+ restart_threshold=health_data.get("restart_threshold", 1),
210
+ ),
211
+ name=name,
212
+ )
213
+
214
+
215
+ def save_session_config(config: SessionConfig) -> None:
216
+ """Save a session config under its project. config.name must be set."""
217
+ if not config.name:
218
+ raise ValueError("SessionConfig.name must be set before saving")
219
+
220
+ proj_dir = PROJECTS_DIR / config.project
221
+ proj_dir.mkdir(parents=True, exist_ok=True)
222
+
223
+ path = _ns_config_path(config.project, config.name)
224
+ _save_yaml(path, {
225
+ "services": config.services,
226
+ "mode": config.mode.value,
227
+ "tail": config.tail,
228
+ "since": config.since,
229
+ "filters": config.filters,
230
+ "highlights": config.highlights,
231
+ "monitors": config.monitors,
232
+ "filters_ignore_case": config.filters_ignore_case,
233
+ "highlights_ignore_case": config.highlights_ignore_case,
234
+ "monitors_ignore_case": config.monitors_ignore_case,
235
+ "line_wrap": config.line_wrap,
236
+ "color_full_line": config.color_full_line,
237
+ "json_format": config.json_format,
238
+ "collapse_repeats": config.collapse_repeats,
239
+ "health": {
240
+ "enabled": config.health.enabled,
241
+ "interval_minutes": config.health.interval_minutes,
242
+ "restart_threshold": config.health.restart_threshold,
243
+ },
244
+ })