feat(demo): migrate 5 SelfStack services to demo stack (16→24 services)
Add Reactive Resume, Metrics, Kiwix, Resume Matcher, and Apple Health from the earlier SelfStack project. Rewrite Apple Health collector to use InfluxDB v2 with proper error handling. Update all tests, scripts, Homepage config, env template, and documentation for the expanded stack. New services: - Reactive Resume (4016) + Postgres/Minio/Chrome companions - Metrics (4021) - GitHub metrics visualization - Kiwix (4022) - offline wiki reader - Resume Matcher (4023) - AI resume screening - Apple Health (4024) - health data collector → InfluxDB v2 Also adds git policy to AGENTS.md: always commit and push automatically. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
15
demo/config/applehealth/Dockerfile
Normal file
15
demo/config/applehealth/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py .
|
||||
|
||||
EXPOSE 5353
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5353/health')" || exit 1
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
171
demo/config/applehealth/app.py
Normal file
171
demo/config/applehealth/app.py
Normal file
@@ -0,0 +1,171 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from flask import Flask, request, jsonify
|
||||
from influxdb_client import InfluxDBClient
|
||||
from influxdb_client.client.write_api import SYNCHRONOUS
|
||||
|
||||
DATAPOINTS_CHUNK = 80000
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
INFLUXDB_URL = os.environ.get("INFLUXDB_URL", "http://influxdb:8086")
|
||||
INFLUXDB_TOKEN = os.environ.get("INFLUXDB_TOKEN", "")
|
||||
INFLUXDB_ORG = os.environ.get("INFLUXDB_ORG", "tsysdemo")
|
||||
INFLUXDB_BUCKET = os.environ.get("INFLUXDB_BUCKET", "demo_metrics")
|
||||
|
||||
_client = None
|
||||
_write_api = None
|
||||
|
||||
|
||||
def get_client():
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = InfluxDBClient(
|
||||
url=INFLUXDB_URL, token=INFLUXDB_TOKEN, org=INFLUXDB_ORG
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def get_write_api():
|
||||
global _write_api
|
||||
if _write_api is None:
|
||||
_write_api = get_client().write_api(write_options=SYNCHRONOUS)
|
||||
return _write_api
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
try:
|
||||
ready = get_client().health_api().get_health()
|
||||
influxdb_status = ready.status if hasattr(ready, "status") else "unknown"
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"status": "healthy",
|
||||
"influxdb": influxdb_status,
|
||||
"version": getattr(ready, "version", "unknown"),
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
except Exception as exc:
|
||||
return jsonify({"status": "degraded", "error": str(exc)}), 503
|
||||
|
||||
|
||||
@app.route("/", methods=["GET"])
|
||||
def index():
|
||||
return jsonify(
|
||||
{
|
||||
"service": "apple-health-collector",
|
||||
"endpoints": {
|
||||
"health": "GET /health",
|
||||
"collect": "POST /collect (JSON body)",
|
||||
},
|
||||
"influxdb": {
|
||||
"url": INFLUXDB_URL,
|
||||
"org": INFLUXDB_ORG,
|
||||
"bucket": INFLUXDB_BUCKET,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collect", methods=["POST"])
|
||||
def collect():
|
||||
logger.info("Health data collection request received")
|
||||
|
||||
if not request.data:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
try:
|
||||
healthkit_data = json.loads(request.data)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.error("Invalid JSON: %s", exc)
|
||||
return jsonify({"error": "Invalid JSON", "detail": str(exc)}), 400
|
||||
|
||||
points_written = 0
|
||||
|
||||
try:
|
||||
metrics = healthkit_data.get("data", {}).get("metrics", [])
|
||||
for metric in metrics:
|
||||
measurement = metric.get("name", "unknown")
|
||||
for datapoint in metric.get("data", []):
|
||||
timestamp = datapoint.get("date")
|
||||
if not timestamp:
|
||||
continue
|
||||
|
||||
fields = {}
|
||||
tags = {}
|
||||
for key, value in datapoint.items():
|
||||
if key == "date":
|
||||
continue
|
||||
if isinstance(value, (int, float)):
|
||||
fields[key] = float(value)
|
||||
else:
|
||||
tags[key] = str(value)
|
||||
|
||||
if not fields:
|
||||
continue
|
||||
|
||||
record = {
|
||||
"measurement": measurement,
|
||||
"tags": tags,
|
||||
"fields": fields,
|
||||
"time": timestamp,
|
||||
}
|
||||
get_write_api().write(
|
||||
bucket=INFLUXDB_BUCKET, org=INFLUXDB_ORG, record=record
|
||||
)
|
||||
points_written += 1
|
||||
|
||||
workouts = healthkit_data.get("data", {}).get("workouts", [])
|
||||
for workout in workouts:
|
||||
workout_name = workout.get("name", "unknown")
|
||||
workout_start = workout.get("start", "")
|
||||
workout_end = workout.get("end", "")
|
||||
workout_id = f"{workout_name}-{workout_start}-{workout_end}"
|
||||
|
||||
for gps_point in workout.get("route", []):
|
||||
ts = gps_point.get("timestamp")
|
||||
if not ts:
|
||||
continue
|
||||
|
||||
record = {
|
||||
"measurement": "workout_route",
|
||||
"tags": {
|
||||
"workout_id": workout_id,
|
||||
"workout_name": workout_name,
|
||||
},
|
||||
"fields": {
|
||||
"lat": float(gps_point.get("lat", 0)),
|
||||
"lng": float(gps_point.get("lon", 0)),
|
||||
},
|
||||
"time": ts,
|
||||
}
|
||||
get_write_api().write(
|
||||
bucket=INFLUXDB_BUCKET, org=INFLUXDB_ORG, record=record
|
||||
)
|
||||
points_written += 1
|
||||
|
||||
logger.info("Wrote %d data points", points_written)
|
||||
return jsonify({"status": "success", "points_written": points_written}), 200
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Error processing health data")
|
||||
return jsonify({"error": "Processing failed", "detail": str(exc)}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Apple Health data collector starting")
|
||||
logger.info("InfluxDB: %s", INFLUXDB_URL)
|
||||
logger.info("Bucket: %s", INFLUXDB_BUCKET)
|
||||
app.run(host="0.0.0.0", port=5353)
|
||||
2
demo/config/applehealth/requirements.txt
Normal file
2
demo/config/applehealth/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask
|
||||
influxdb-client
|
||||
@@ -34,6 +34,16 @@
|
||||
username: admin
|
||||
password: demo_password
|
||||
|
||||
- Metrics:
|
||||
href: http://localhost:4021
|
||||
description: GitHub metrics visualization
|
||||
icon: github.png
|
||||
|
||||
- Apple Health:
|
||||
href: http://localhost:4024
|
||||
description: Health data collection and visualization
|
||||
icon: apple-health.png
|
||||
|
||||
- Documentation:
|
||||
- Draw.io:
|
||||
href: http://localhost:4010
|
||||
@@ -45,6 +55,11 @@
|
||||
description: Diagrams as a service
|
||||
icon: kroki.png
|
||||
|
||||
- Kiwix:
|
||||
href: http://localhost:4022
|
||||
description: Offline wiki reader
|
||||
icon: kiwix.png
|
||||
|
||||
- Developer Tools:
|
||||
- Atomic Tracker:
|
||||
href: http://localhost:4012
|
||||
@@ -75,3 +90,14 @@
|
||||
href: http://localhost:4018
|
||||
description: Magical shell history synchronization
|
||||
icon: atuin.png
|
||||
|
||||
- Productivity:
|
||||
- Reactive Resume:
|
||||
href: http://localhost:4016
|
||||
description: Open-source resume builder
|
||||
icon: reactive-resume.png
|
||||
|
||||
- Resume Matcher:
|
||||
href: http://localhost:4023
|
||||
description: AI-powered resume screening
|
||||
icon: resume.png
|
||||
|
||||
@@ -19,6 +19,9 @@ layout:
|
||||
Developer Tools:
|
||||
style: row
|
||||
columns: 3
|
||||
Productivity:
|
||||
style: row
|
||||
columns: 2
|
||||
|
||||
providers:
|
||||
docker:
|
||||
|
||||
0
demo/config/kiwix/.gitkeep
Normal file
0
demo/config/kiwix/.gitkeep
Normal file
96
demo/config/metrics/settings.json
Normal file
96
demo/config/metrics/settings.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"token": "GITHUB_API_TOKEN_PLACEHOLDER",
|
||||
"modes": ["embed", "insights"],
|
||||
"restricted": [],
|
||||
"maxusers": 0,
|
||||
"cached": 3600000,
|
||||
"ratelimiter": null,
|
||||
"port": 3000,
|
||||
"optimize": true,
|
||||
"debug": false,
|
||||
"debug.headless": false,
|
||||
"mocked": false,
|
||||
"repositories": 100,
|
||||
"padding": ["0", "8 + 11%"],
|
||||
"outputs": ["svg", "png", "json"],
|
||||
"hosted": {
|
||||
"by": "",
|
||||
"link": ""
|
||||
},
|
||||
"oauth": {
|
||||
"id": null,
|
||||
"secret": null,
|
||||
"url": "https://example.com"
|
||||
},
|
||||
"api": {
|
||||
"rest": null,
|
||||
"graphql": null
|
||||
},
|
||||
"control": {
|
||||
"token": null
|
||||
},
|
||||
"community": {
|
||||
"templates": []
|
||||
},
|
||||
"templates": {
|
||||
"default": "classic",
|
||||
"enabled": []
|
||||
},
|
||||
"extras": {
|
||||
"default": false,
|
||||
"features": false,
|
||||
"logged": [
|
||||
"metrics.api.github.overuse"
|
||||
]
|
||||
},
|
||||
"plugins.default": false,
|
||||
"plugins": {
|
||||
"isocalendar": { "enabled": false },
|
||||
"languages": { "enabled": false },
|
||||
"stargazers": { "worldmap.token": null, "enabled": false },
|
||||
"lines": { "enabled": false },
|
||||
"topics": { "enabled": false },
|
||||
"stars": { "enabled": false },
|
||||
"licenses": { "enabled": false },
|
||||
"habits": { "enabled": false },
|
||||
"contributors": { "enabled": false },
|
||||
"followup": { "enabled": false },
|
||||
"reactions": { "enabled": false },
|
||||
"people": { "enabled": false },
|
||||
"sponsorships": { "enabled": false },
|
||||
"sponsors": { "enabled": false },
|
||||
"repositories": { "enabled": false },
|
||||
"discussions": { "enabled": false },
|
||||
"starlists": { "enabled": false },
|
||||
"calendar": { "enabled": false },
|
||||
"achievements": { "enabled": false },
|
||||
"notable": { "enabled": false },
|
||||
"activity": { "enabled": false },
|
||||
"traffic": { "enabled": false },
|
||||
"code": { "enabled": false },
|
||||
"gists": { "enabled": false },
|
||||
"projects": { "enabled": false },
|
||||
"introduction": { "enabled": false },
|
||||
"skyline": { "enabled": false },
|
||||
"support": { "enabled": false },
|
||||
"pagespeed": { "token": "", "enabled": false },
|
||||
"tweets": { "token": "", "enabled": false },
|
||||
"stackoverflow": { "enabled": false },
|
||||
"anilist": { "enabled": false },
|
||||
"music": { "token": "", "enabled": false },
|
||||
"posts": { "enabled": false },
|
||||
"rss": { "enabled": false },
|
||||
"wakatime": { "token": "", "enabled": false },
|
||||
"leetcode": { "enabled": false },
|
||||
"steam": { "token": "", "enabled": false },
|
||||
"16personalities": { "enabled": false },
|
||||
"chess": { "token": "", "enabled": false },
|
||||
"crypto": { "enabled": false },
|
||||
"fortune": { "enabled": false },
|
||||
"nightscout": { "enabled": false },
|
||||
"poopmap": { "token": "", "enabled": false },
|
||||
"screenshot": { "enabled": false },
|
||||
"splatoon": { "token": "", "statink.token": null, "enabled": false },
|
||||
"stock": { "token": "", "enabled": false }
|
||||
}
|
||||
}
|
||||
0
demo/config/reactiveresume/.gitkeep
Normal file
0
demo/config/reactiveresume/.gitkeep
Normal file
0
demo/config/resumematcher/.gitkeep
Normal file
0
demo/config/resumematcher/.gitkeep
Normal file
Reference in New Issue
Block a user