A CLI wizard for examples (#1)
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
04_traces_with_tempo/tempo/data
|
04_traces_with_tempo/tempo/data
|
||||||
.idea
|
.idea
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
__pycache__
|
||||||
|
.DS_Store
|
16
README.md
16
README.md
@@ -1,11 +1,19 @@
|
|||||||
5 ways to get started with Grafana
|
# 5 ways to get started with Grafana
|
||||||
=====
|
|
||||||
|
|
||||||
This repository contains a collection of resources and examples to back up the blog post "5 ways to get started with Grafana".
|
This repository contains a collection of resources and examples to back up the blog post "5 ways to get started with Grafana".
|
||||||
Click [here](https://TODO) to read the blog post.
|
Click [here](https://quesma.com/blog-detail/five-docker-examples-for-grafana-to-get-started-with-metrics-logs-and-traces) to read the blog post.
|
||||||
|
|
||||||
|
|
||||||
### Repository layout
|
### Repository layout
|
||||||
|
|
||||||
Each of the subdirectories in this repository corresponds to a specific example, showcasing a different local Grafana setup.
|
Each of the subdirectories in this repository corresponds to a specific example, showcasing a different local Grafana setup.
|
||||||
Follow instructions in the respective `README.md` files within each subdirectory to set up and run the examples.
|
Follow instructions in the respective `README.md` files within each subdirectory to set up and run the examples.
|
||||||
|
|
||||||
|
|
||||||
|
## (optional) Interactive wizard to run these examples
|
||||||
|
Alternatively, use the interactive wizard to set up and run any of the examples with a single command via [uv](https://docs.astral.sh/uv/guides/scripts/).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run setup_wizard.py
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
BIN
setup_wizard.png
Normal file
BIN
setup_wizard.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 166 KiB |
425
setup_wizard.py
Normal file
425
setup_wizard.py
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
#!/usr/bin/env uv run
|
||||||
|
# /// script
|
||||||
|
# dependencies = [
|
||||||
|
# "questionary==2.1.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import webbrowser
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import questionary
|
||||||
|
from questionary import Style
|
||||||
|
|
||||||
|
|
||||||
|
# ANSI color codes for minimal, elegant styling
|
||||||
|
class Colors:
|
||||||
|
CYAN = '\033[96m'
|
||||||
|
GREEN = '\033[92m'
|
||||||
|
GRAY = '\033[90m'
|
||||||
|
YELLOW = '\033[93m'
|
||||||
|
RESET = '\033[0m'
|
||||||
|
BOLD = '\033[1m'
|
||||||
|
|
||||||
|
|
||||||
|
# Custom style for questionary to match our theme
|
||||||
|
custom_style = Style([
|
||||||
|
('qmark', 'fg:#f59e0b'), # question mark - yellowish
|
||||||
|
('question', ''), # question text
|
||||||
|
('answer', 'fg:#f59e0b bold'), # submitted answer - yellowish
|
||||||
|
('pointer', 'fg:#f59e0b bold'), # pointer in select - yellowish
|
||||||
|
('highlighted', 'fg:#fbbf24'), # highlighted choice - lighter yellow
|
||||||
|
('selected', 'fg:#f59e0b'), # selected item - yellowish
|
||||||
|
('separator', 'fg:#6c6c6c'), # separator
|
||||||
|
('instruction', 'fg:#858585'), # instructions
|
||||||
|
('text', ''), # plain text
|
||||||
|
('disabled', 'fg:#858585'), # disabled choices
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Scenario:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
def run(self, base_dir: Path, port: int = 3000) -> None:
|
||||||
|
"""Execute this scenario."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def _ask_show_logs(self) -> bool:
|
||||||
|
"""Ask user if they want to see full Docker logs."""
|
||||||
|
return questionary.confirm("Show full Docker logs?", default=True, style=custom_style).ask()
|
||||||
|
|
||||||
|
def _show_ready_message(self, port: int) -> None:
|
||||||
|
"""Display the setup complete message."""
|
||||||
|
print(f"\n{Colors.GREEN}✓{Colors.RESET} Grafana is ready")
|
||||||
|
print(f"\n {Colors.CYAN}http://localhost:{port}{Colors.RESET}")
|
||||||
|
print(f" {Colors.GRAY}admin / admin{Colors.RESET}")
|
||||||
|
print(f"\n{Colors.GRAY}Press Ctrl+C to stop{Colors.RESET}")
|
||||||
|
|
||||||
|
def _ask_open_browser(self, port: int) -> bool:
|
||||||
|
"""Ask user if they want to open browser."""
|
||||||
|
return questionary.confirm("Open in browser?", default=True, style=custom_style).ask()
|
||||||
|
|
||||||
|
|
||||||
|
class StandaloneGrafana(Scenario):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
id="01_standalone_grafana",
|
||||||
|
name="Standalone Grafana",
|
||||||
|
description="Vanilla Grafana instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self, base_dir: Path, port: int = 3000) -> None:
|
||||||
|
cmd_str = f"docker run -i -t --rm -p {port}:3000 grafana/grafana:12.0.2"
|
||||||
|
print(f"\n{Colors.GRAY}→ Starting {self.name}...{Colors.RESET}")
|
||||||
|
print(f" {Colors.GRAY}{cmd_str}{Colors.RESET}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
show_logs = self._ask_show_logs()
|
||||||
|
self._show_ready_message(port)
|
||||||
|
|
||||||
|
if self._ask_open_browser(port):
|
||||||
|
webbrowser.open(f"http://localhost:{port}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
subprocess.run([
|
||||||
|
"docker", "run", "-i", "-t", "--rm", "-p", f"{port}:3000",
|
||||||
|
"grafana/grafana:12.0.2"
|
||||||
|
], check=True, capture_output=not show_logs, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeBased(Scenario):
|
||||||
|
"""Base class for docker-compose based scenarios."""
|
||||||
|
|
||||||
|
def run(self, base_dir: Path, port: int = 3000) -> None:
|
||||||
|
scenario_dir = base_dir / self.id
|
||||||
|
if not scenario_dir.exists():
|
||||||
|
print(f"{Colors.YELLOW}Directory '{self.id}' not found{Colors.RESET}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cmd_str = f"cd {self.id} && docker-compose up"
|
||||||
|
print(f"\n{Colors.GRAY}→ Starting {self.name}...{Colors.RESET}")
|
||||||
|
print(f" {Colors.GRAY}{cmd_str}{Colors.RESET}")
|
||||||
|
|
||||||
|
if port != 3000:
|
||||||
|
print(f"\n {Colors.YELLOW}Using port {Colors.CYAN}{port}{Colors.YELLOW} instead of default 3000{Colors.RESET}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
show_logs = self._ask_show_logs()
|
||||||
|
self._show_ready_message(port)
|
||||||
|
|
||||||
|
if self._ask_open_browser(port):
|
||||||
|
webbrowser.open(f"http://localhost:{port}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if port != 3000:
|
||||||
|
# Create a temporary docker-compose override
|
||||||
|
override_content = f"""services:
|
||||||
|
grafana:
|
||||||
|
ports:
|
||||||
|
- "{port}:3000"
|
||||||
|
"""
|
||||||
|
override_file = scenario_dir / "docker-compose.override.yml"
|
||||||
|
try:
|
||||||
|
with open(override_file, 'w') as f:
|
||||||
|
f.write(override_content)
|
||||||
|
|
||||||
|
subprocess.run(["docker-compose", "up"], cwd=scenario_dir,
|
||||||
|
check=True, capture_output=not show_logs, text=True)
|
||||||
|
finally:
|
||||||
|
# Clean up override file
|
||||||
|
if override_file.exists():
|
||||||
|
override_file.unlink()
|
||||||
|
else:
|
||||||
|
subprocess.run(["docker-compose", "up"], cwd=scenario_dir,
|
||||||
|
check=True, capture_output=not show_logs, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PrometheusSetup(ComposeBased):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
id="02_metrics_with_prometheus",
|
||||||
|
name="Grafana + Prometheus",
|
||||||
|
description="Basic metrics collection"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LokiSetup(ComposeBased):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
id="03_logs_with_loki",
|
||||||
|
name="Grafana + Loki",
|
||||||
|
description="Log aggregation and exploration"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TempoSetup(ComposeBased):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
id="04_traces_with_tempo",
|
||||||
|
name="Grafana + Tempo",
|
||||||
|
description="Distributed tracing"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PyroscopeSetup(ComposeBased):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
id="05_profiling_with_pyroscope",
|
||||||
|
name="Grafana + Pyroscope",
|
||||||
|
description="Continuous profiling"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_port_available(port: int) -> bool:
|
||||||
|
"""Check if a port is available."""
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.settimeout(1)
|
||||||
|
result = sock.connect_ex(('localhost', port))
|
||||||
|
return result != 0
|
||||||
|
except socket.error:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_docker_containers() -> list[str]:
|
||||||
|
"""Check for existing Docker containers that might conflict."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "-a", "--format", "{{.Names}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
return result.stdout.strip().split('\n') if result.stdout.strip() else []
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def handle_container_conflicts(existing_containers: list[str]) -> bool:
|
||||||
|
"""Handle Docker container conflicts."""
|
||||||
|
conflicting = [name for name in ['grafana', 'prometheus', 'loki', 'tempo']
|
||||||
|
if name in existing_containers]
|
||||||
|
|
||||||
|
if not conflicting:
|
||||||
|
return True
|
||||||
|
|
||||||
|
print(f"\n{Colors.YELLOW}Found existing containers:{Colors.RESET} {', '.join(conflicting)}")
|
||||||
|
|
||||||
|
choice = questionary.select(
|
||||||
|
"These containers may conflict. How would you like to proceed?",
|
||||||
|
choices=[
|
||||||
|
"Remove conflicting containers (recommended)",
|
||||||
|
"Stop conflicting containers",
|
||||||
|
"Continue anyway (may fail)",
|
||||||
|
"Exit to handle manually"
|
||||||
|
],
|
||||||
|
style=custom_style
|
||||||
|
).ask()
|
||||||
|
|
||||||
|
if not choice or "Exit" in choice:
|
||||||
|
return False
|
||||||
|
elif "Remove" in choice:
|
||||||
|
for container in conflicting:
|
||||||
|
try:
|
||||||
|
subprocess.run(["docker", "rm", "-f", container],
|
||||||
|
capture_output=True, check=True)
|
||||||
|
print(f" {Colors.GREEN}✓{Colors.RESET} Removed container: {Colors.GRAY}{container}{Colors.RESET}")
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
print(f" {Colors.YELLOW}!{Colors.RESET} Could not remove container: {Colors.GRAY}{container}{Colors.RESET}")
|
||||||
|
elif "Stop" in choice:
|
||||||
|
for container in conflicting:
|
||||||
|
try:
|
||||||
|
subprocess.run(["docker", "stop", container],
|
||||||
|
capture_output=True, check=True)
|
||||||
|
print(f" {Colors.GREEN}✓{Colors.RESET} Stopped container: {Colors.GRAY}{container}{Colors.RESET}")
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
print(f" {Colors.YELLOW}!{Colors.RESET} Could not stop container: {Colors.GRAY}{container}{Colors.RESET}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def find_next_available_port(start_port: int = 3000) -> int:
|
||||||
|
"""Find the next available port starting from start_port."""
|
||||||
|
port = start_port
|
||||||
|
while port < 65535:
|
||||||
|
if is_port_available(port):
|
||||||
|
return port
|
||||||
|
port += 1
|
||||||
|
raise RuntimeError("No available ports found")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_port_conflict(port: int = 3000) -> int:
|
||||||
|
"""Handle port conflicts with user-friendly options."""
|
||||||
|
next_port = find_next_available_port(port + 1)
|
||||||
|
|
||||||
|
print(f"\n{Colors.YELLOW}Port {Colors.CYAN}{port}{Colors.YELLOW} is already in use{Colors.RESET}")
|
||||||
|
|
||||||
|
choice = questionary.select(
|
||||||
|
"How would you like to proceed?",
|
||||||
|
choices=[
|
||||||
|
f"Use port {next_port} (recommended)",
|
||||||
|
"Choose custom port",
|
||||||
|
"Exit to handle manually"
|
||||||
|
],
|
||||||
|
style=custom_style
|
||||||
|
).ask()
|
||||||
|
|
||||||
|
if not choice or "Exit" in choice:
|
||||||
|
sys.exit(0)
|
||||||
|
elif "custom port" in choice:
|
||||||
|
while True:
|
||||||
|
print()
|
||||||
|
custom_port = questionary.text("Enter port number:", style=custom_style).ask()
|
||||||
|
if not custom_port:
|
||||||
|
sys.exit(0)
|
||||||
|
try:
|
||||||
|
port_num = int(custom_port)
|
||||||
|
if 1024 <= port_num <= 65535:
|
||||||
|
if is_port_available(port_num):
|
||||||
|
return port_num
|
||||||
|
else:
|
||||||
|
print(f" {Colors.YELLOW}Port {Colors.CYAN}{port_num}{Colors.YELLOW} is also in use. Please try another.{Colors.RESET}")
|
||||||
|
else:
|
||||||
|
print(f" {Colors.YELLOW}Port must be between 1024 and 65535.{Colors.RESET}")
|
||||||
|
except ValueError:
|
||||||
|
print(f" {Colors.YELLOW}Please enter a valid port number.{Colors.RESET}")
|
||||||
|
else:
|
||||||
|
return next_port
|
||||||
|
|
||||||
|
|
||||||
|
def check_docker() -> tuple[bool, str | None]:
|
||||||
|
"""Check if Docker is installed and return version info."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
return True, result.stdout.strip()
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
def handle_missing_docker() -> None:
|
||||||
|
"""Handle case when Docker is not installed."""
|
||||||
|
choice = questionary.select(
|
||||||
|
"Docker is required. What would you like to do?",
|
||||||
|
choices=[
|
||||||
|
"Open installation page",
|
||||||
|
"Exit"
|
||||||
|
],
|
||||||
|
style=custom_style
|
||||||
|
).ask()
|
||||||
|
|
||||||
|
if choice == "Open installation page":
|
||||||
|
webbrowser.open("https://docs.docker.com/get-docker/")
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def get_scenarios() -> list[Scenario]:
|
||||||
|
"""Return list of available scenarios."""
|
||||||
|
return [
|
||||||
|
StandaloneGrafana(),
|
||||||
|
PrometheusSetup(),
|
||||||
|
LokiSetup(),
|
||||||
|
TempoSetup(),
|
||||||
|
PyroscopeSetup()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def select_scenario() -> Scenario | None:
|
||||||
|
"""Display scenario selection menu and return choice."""
|
||||||
|
scenarios = get_scenarios()
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
choices = [f"{s.id.split('_')[0]} · {s.name} – {s.description}" for s in scenarios]
|
||||||
|
|
||||||
|
selected = questionary.select("Select scenario:", choices=choices, style=custom_style).ask()
|
||||||
|
print()
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scenario_num = selected[:2]
|
||||||
|
return next(s for s in scenarios if s.id.startswith(scenario_num))
|
||||||
|
|
||||||
|
|
||||||
|
def run_scenario(scenario: Scenario, port: int) -> None:
|
||||||
|
"""Execute the selected scenario with proper error handling."""
|
||||||
|
base_dir = Path(__file__).parent
|
||||||
|
|
||||||
|
try:
|
||||||
|
scenario.run(base_dir, port)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"\n{Colors.YELLOW}Error:{Colors.RESET} {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\n{Colors.GRAY}→ Stopping {scenario.name}...{Colors.RESET}")
|
||||||
|
|
||||||
|
if scenario.id != "01_standalone_grafana":
|
||||||
|
scenario_dir = base_dir / scenario.id
|
||||||
|
try:
|
||||||
|
subprocess.run(["docker-compose", "down"], cwd=scenario_dir)
|
||||||
|
print(f"{Colors.GREEN}✓{Colors.RESET} Services stopped")
|
||||||
|
|
||||||
|
# Clean up any override file
|
||||||
|
override_file = scenario_dir / "docker-compose.override.yml"
|
||||||
|
if override_file.exists():
|
||||||
|
override_file.unlink()
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
print(f"{Colors.YELLOW}!{Colors.RESET} Some services may still be running")
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main function."""
|
||||||
|
print(f"\n{Colors.BOLD}Grafana Setup Wizard{Colors.RESET}")
|
||||||
|
print(f"{Colors.GRAY}Interactive setup for Grafana scenarios{Colors.RESET}\n")
|
||||||
|
|
||||||
|
docker_available, docker_version = check_docker()
|
||||||
|
|
||||||
|
if not docker_available:
|
||||||
|
handle_missing_docker()
|
||||||
|
return
|
||||||
|
|
||||||
|
version_parts = docker_version.split(' ')
|
||||||
|
if len(version_parts) >= 3:
|
||||||
|
print(f"{Colors.GREEN}✓{Colors.RESET} Docker {Colors.CYAN}{version_parts[2].rstrip(',')}{Colors.RESET}")
|
||||||
|
else:
|
||||||
|
print(f"{Colors.GREEN}✓{Colors.RESET} {docker_version}")
|
||||||
|
|
||||||
|
# Check for container conflicts
|
||||||
|
existing_containers = check_docker_containers()
|
||||||
|
if not handle_container_conflicts(existing_containers):
|
||||||
|
print(f"\n{Colors.GRAY}Exiting...{Colors.RESET}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check port availability
|
||||||
|
port = 3000
|
||||||
|
if not is_port_available(port):
|
||||||
|
port = handle_port_conflict(port)
|
||||||
|
else:
|
||||||
|
print(f"{Colors.GREEN}✓{Colors.RESET} Port {Colors.CYAN}{port}{Colors.RESET} available")
|
||||||
|
|
||||||
|
scenario = select_scenario()
|
||||||
|
if scenario:
|
||||||
|
run_scenario(scenario, port)
|
||||||
|
else:
|
||||||
|
print(f"\n{Colors.GRAY}No scenario selected{Colors.RESET}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
Reference in New Issue
Block a user