[
  {
    "path": ".github/workflows/docker-api.yaml",
    "content": "name: API Docker build\n\non:\n  push:\n\njobs:\n  docker:\n    runs-on: ubuntu-latest\n\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      -\n        uses: actions/checkout@v3\n      -\n        name: Set up QEMU\n        uses: docker/setup-qemu-action@v2\n        with:\n          platforms: arm64\n      -\n        name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v2\n      -\n        name: Docker image metadata\n        id: meta\n        uses: docker/metadata-action@v4\n        with:\n          images: ghcr.io/${{ github.repository }}/api\n          tags: |\n            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}\n            type=raw,value=main,enable=${{ github.ref == 'refs/heads/main' }}\n            type=ref,event=pr\n            type=sha\n      -\n        name: Login to GitHub Container Registry\n        uses: docker/login-action@v2\n        with:\n            registry: ghcr.io\n            username: ${{ github.actor }}\n            password: ${{ secrets.GITHUB_TOKEN }}\n      -\n        name: Build and push\n        uses: docker/build-push-action@v4\n        with:\n          context: .\n          platforms: linux/amd64,linux/arm64\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n"
  },
  {
    "path": ".github/workflows/docker-frontend.yaml",
    "content": "name: Frontend Docker build\n\non:\n  push:\n\njobs:\n  docker:\n    runs-on: ubuntu-latest\n    defaults:\n      run:\n        working-directory: \"frontend\"\n\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - uses: actions/checkout@v3\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v2\n        with:\n          platforms: arm64\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v2\n      - name: Docker image metadata\n        id: meta\n        uses: docker/metadata-action@v4\n        with:\n          images: ghcr.io/${{ github.repository }}/frontend\n          tags: |\n            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}\n            type=raw,value=main,enable=${{ github.ref == 'refs/heads/main' }}\n            type=ref,event=pr\n            type=sha\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v2\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - uses: pnpm/action-setup@v2\n        with:\n          version: 8.6.12\n      - name: Install Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version-file: frontend/.node-version\n          cache: \"pnpm\"\n          cache-dependency-path: frontend/pnpm-lock.yaml\n\n      - name: Install dependencies\n        run: pnpm i --frozen-lockfile\n\n      - name: Build\n        run: pnpm build\n\n      - name: Build and push\n        uses: docker/build-push-action@v4\n        with:\n          context: frontend/\n          platforms: linux/amd64,linux/arm64\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n"
  },
  {
    "path": ".github/workflows/release-chart.yaml",
    "content": "name: Release Chart\n\non:\n  pull_request:\n    paths:\n      - charts/**\n      - .github/workflows/release-chart.yaml\n  push:\n    branches:\n      - main\n    paths:\n      - charts/**\n      - .github/workflows/release-chart.yaml\n\njobs:\n  release:\n    name: Release chart to repo\n    runs-on: ubuntu-latest\n\n    permissions:\n      contents: write\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v3\n\n      - name: Install Helm\n        uses: azure/setup-helm@v1\n        with:\n          version: v3.12.2\n\n      - name: Add -<sha> to version in charts/housewatch/Chart.yaml and update Chart.lock\n        if: github.ref != 'refs/heads/main'\n        run: |\n          sed -i 's/^version: \\(.*\\)$/version: \\1-${{ github.sha }}/g' charts/housewatch/Chart.yaml\n\n      - name: Configure Git\n        run: |\n          git config user.name \"Max Hedgehog\"\n          git config user.email \"127861667+max-hedgehog[bot]@users.noreply.github.com\"\n          git fetch origin gh-pages --depth=1\n\n      - name: Package\n        run: |\n          helm dependency update charts/housewatch/\n          mkdir -p .cr-release-packages\n          cd .cr-release-packages\n          helm package ../charts/housewatch\n          cd -\n          set -x\n\n      - name: Run chart-releaser\n        uses: helm/chart-releaser-action@4b85f2c82c80ff4284ff8520f47bbe69dd89b0aa\n        if: github.ref == 'refs/heads/main' && github.repository == 'PostHog/HouseWatch'\n        env:\n          CR_TOKEN: \"${{ secrets.GITHUB_TOKEN }}\"\n        with:\n          skip_existing: true\n          skip_packaging: true\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#pdm.lock\n#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it\n#   in version control.\n#   https://pdm.fming.dev/#use-with-ide\n.pdm.toml\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n#  and can be added to the global gitignore or merged into this file.  For a more nuclear\n#  option (not recommended) you can uncomment the following to ignore the entire idea folder.\n#.idea/\n\n\nenv/\n__pycache__/\nnode_modules\nhousewatch.sqlite3\n.DS_Store\nyarn.lock\n\ncharts/housewatch/charts/\n.pnpm/"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v2.3.0\n    hooks:\n      - id: check-yaml\n      - id: end-of-file-fixer\n      - id: trailing-whitespace\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    # Ruff version.\n    rev: v0.0.275\n    hooks:\n      - id: ruff\n        args: [--fix, --exit-non-zero-on-fix]\n  - repo: https://github.com/psf/black\n    rev: 22.10.0\n    hooks:\n      - id: black\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM python:3.10\n\nENV PYTHONUNBUFFERED 1\n\nWORKDIR /code\n\nCOPY requirements.txt ./\n\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends \\\n    \"build-essential\" \\\n    \"git\" \\\n    \"libpq-dev\" \\\n    \"libxmlsec1\" \\\n    \"libxmlsec1-dev\" \\\n    \"libffi-dev\" \\\n    \"pkg-config\" \\\n    && \\\n    rm -rf /var/lib/apt/lists/* && \\\n    pip install -r requirements.txt --compile --no-cache-dir\n\n\nUSER root\n\nCOPY manage.py manage.py\nCOPY housewatch housewatch/\nCOPY bin bin/\n\nRUN DEBUG=1 python manage.py collectstatic --noinput\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023 PostHog\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <img src=\"./banner-light.png\">\n</p>\n\n\n<p align=\"center\">\n  <img src=\"./overview.png\">\n</p>\n\n\n## 📈 Open source tool for monitoring and managing ClickHouse clusters\n\n- Get an overview of cluster load and performance\n- Drill down into your queries and understand the load they put on your cluster\n- Search through logs and errors\n- Monitor and kill running queries with the click of a button\n- Get stats on your disk usage per node, and understand how much disk space tables, columns, and parts take up\n- Run your own queries straight from the interface to further dig into performance and cluster issues\n- Setup operations to run in the background with automatic rollbacks for failures\n\n## 💻 Deploy\n\nTo deploy HouseWatch, clone this repo and then run the following, substituting the environment variables for the relevant values of one of your ClickHouse instances:\n\n```bash\nSITE_ADDRESS=<SITE_ADDRESS> \\\nCLICKHOUSE_HOST=localhost \\\nCLICKHOUSE_CLUSTER=mycluster \\\nCLICKHOUSE_USER=default \\\nCLICKHOUSE_PASSWORD=xxxxxxxxxxx \\\ndocker compose -f docker-compose.yml up\n```\n\n`SITE_ADDRESS` here is the address that the UI will be running on. It can be a domain name or simply a port like `:80`.\n\nAfter running the above, the UI will be running on the address you specified. This will be something like http://localhost if you used `:80` for your `SITE_ADDRESS` above. I would think twice about exposing this to the internet, as it is not currently secured in any way.\n\n<details>\n\n<summary>Read more</summary>\n\n<br />\n\nThe following are the supported environment variables for configuring your HouseWatch deployment:\n\n- `CLICKHOUSE_HOST`: Required - hostname of the instance to connect to.\n- `CLICKHOUSE_USER`: Required - username to access ClickHouse. Can be a read-only user, but in that case not all features will work.\n- `CLICKHOUSE_PASSWORD`: Required - password for the specified user.\n- `CLICKHOUSE_DATABASE`: Optional - database to connect to by default.\n- `CLICKHOUSE_CLUSTER`: Optional - cluster name, to analyze data from the whole cluster.\n- `CLICKHOUSE_SECURE`: Optional - see [clickhouse-driver docs](https://clickhouse-driver.readthedocs.io/en/latest/index.html) for more information\n- `CLICKHOUSE_VERIFY`: Optional - see [clickhouse-driver docs](https://clickhouse-driver.readthedocs.io/en/latest/index.html) for more information\n- `CLICKHOUSE_CA`: Optional - see [clickhouse-driver docs](https://clickhouse-driver.readthedocs.io/en/latest/index.html) for more information\n- `OPENAI_API_KEY`: Optional - enables the experimental \"AI Tools\" page, which currently features a natural language query editor\n- `OPENAI_MODEL`: Optional - a valid OpenAI model (e.g. `gpt-3.5-turbo`, `gpt-4`) that you have access to with the key above to be used for the AI features\n\n</details>\n\n## 🏡 Running locally\n\nTo run HouseWatch locally along with a local ClickHouse instance, execute: \n\n```bash\ndocker compose -f docker-compose.dev.yml up -d\n```\n\nthen go to http://localhost:8080\n\n## 💡 Motivation\n\nAt PostHog we manage a few large ClickHouse clusters and found ourselves in need of a tool to monitor and manage these more easily.\n\nClickHouse is fantastic at introspection, providing a lot of metadata about the system in its system tables so that it can be easily queried. However, knowing exactly how to query and parse the available information can be a difficult task. Over the years at PostHog, we've developed great intuition for how to debug ClickHouse issues using ClickHouse, and HouseWatch is the compilation of this knowledge into a tool.\n\nBeyond monitoring, we also built internal systems and processes for managing the clusters that spanned various platforms. We would use Grafana to look at metrics, SSH into nodes for running operations and using specialized tooling, query via Metabase to dig deeper into the data in the system tables and create dashboards, and then a combination of tools baked into the PostHog product for further debugging and streamlined operations such as our [async migrations](https://posthog.com/blog/async-migrations) tool, and internal views for listing queries and analyzing their performance.\n\nAs a result, we felt it was appropriate to have these tools live in one place. Ultimately, our vision for HouseWatch is that it can both serve the purpose of a pganalyze for the ClickHouse ecosystem, while also including tooling for taking action on insights derived from the analysis.\n\n## 🏗️ Status of the project\n\nHouseWatch is in its early days and we have a lot more features in mind that we'd like to build into it going forward. The code could also use some cleaning up :) As of right now, it is considered Beta software and you should exercise caution when using it in production.\n\nOne potential approach is to connect HouseWatch to ClickHouse using a read-only user. In this case, the cluster management features will not work (e.g. operations, query editor), but the analysis toolset will function normally.\n\nHouseWatch was created and is maintained by [PostHog](https://posthog.com) and [yakkomajuri](https://github.com/yakkomajuri).\n\n## ℹ️ Contributing\n\nContributions are certainly welcome! However, if you'd like to build a new feature, please open up an issue first.\n\n## ⭐ Features\n\n<h2 align=\"center\">Query performance</h3>\n\n<div style=\"display: flex\">\n  <img src=\"./slow-queries.png\" width=\"48%\">\n  <img src=\"./normalized-query.png\" width=\"48%\">\n</div>\n\n<div style=\"display: flex\">\n  <img src=\"./query-stats.png\" width=\"48%\">\n  <img src=\"./explain.png\" width=\"48%\">\n</div>\n\n<br />\n<h2 align=\"center\">Schema stats</h3>\n\n<div style=\"display: flex\">\n  <img src=\"./schema.png\" width=\"48%\">\n  <img src=\"./schema-drilldown.png\" width=\"48%\">\n</div>\n\n<br />\n<h2 align=\"center\">Query benchmarking</h3>\n\n<div style=\"display: flex\">\n  <img src=\"./benchmark1.png\" width=\"48%\">\n  <img src=\"./benchmark2.png\" width=\"48%\">\n</div>\n\n<br />\n<h2 align=\"center\">Logs</h3>\n\n<p align=\"center\">\n<img src=\"./logs.png\" align=\"center\">\n</p>\n\n<br />\n<h2 align=\"center\">Query editor</h3>\n\n<p align=\"center\">\n<img src=\"./query-editor.png\">\n</p>\n\n<br />\n<h2 align=\"center\">Disk usage</h3>\n\n<p align=\"center\">\n<img src=\"./disk-usage.png\">\n</p>\n\n<br />\n<h2 align=\"center\">Errors</h3>\n\n<p align=\"center\">\n<img src=\"./errors.png\">\n</p>\n\n<br />\n<h2 align=\"center\">Operations</h3>\n\n<p align=\"center\">\n<img src=\"./operations.png\">\n</p>\n\n\n\n## 🗒️ To-do list\n\nA public list of things we intend to do with HouseWatch in the near future.\n\n<details>\n\n<summary>See list</summary>\n\n<br />\n\n<b>Features</b>\n\n- [ ] System issues tab\n- [ ] EXPLAIN visualizer\n- [ ] Multiple instance support\n- [ ] Stats on page cache hit percentage\n- [ ] Make operations resilient to Celery going down (as we do in PostHog with async migrations)\n- [ ] Read-only mode\n- [ ] Button to force refresh running queries list\n- [ ] Logs pagination\n- [ ] Allow copying example queries\n- [ ] Configurable time ranges\n- [ ] Whole cluster schema stats\n- [ ] More operation controls: view, delete, edit, re-run, display errors\n\n<b>Developer experience</b>\n\n- [ ] Configure instance from UI\n- [ ] Publish a Docker image\n- [ ] Development docker-compose.yml with baked in ClickHouse\n\n<b>Cleanup</b>\n\n- [ ] Extract README images out of repo\n- [ ] Make banner subtitle work on dark mode\n- [ ] Fetch data independently on the query analyzer\n- [ ] Breakpoint for logs search\n- [ ] Run Django \"production server\"\n- [ ] Write tests :)\n- [ ] Query editor pipe all errors to client\n- [ ] Abstraction to load data from API as JSON\n\n</details>\n"
  },
  {
    "path": "bin/celery",
    "content": "#!/bin/bash\nset -e\n\ncelery -A housewatch worker -B\n"
  },
  {
    "path": "bin/docker",
    "content": "#!/bin/bash\nset -e\n\n./bin/migrate\n./bin/serve\n"
  },
  {
    "path": "bin/migrate",
    "content": "#!/bin/bash\nset -e\n\npython manage.py migrate\n"
  },
  {
    "path": "bin/serve",
    "content": "#!/bin/bash\nexec gunicorn housewatch.wsgi -c housewatch/gunicorn.conf.py \\\n    --worker-tmp-dir /dev/shm\n"
  },
  {
    "path": "bin/start",
    "content": "#!/bin/bash\n\nset -e\n\nexport DEBUG=1\n\n./bin/celery & python manage.py runserver\n"
  },
  {
    "path": "charts/housewatch/.helmignore",
    "content": "# Patterns to ignore when building packages.\n# This supports shell glob matching, relative path matching, and\n# negation (prefixed with !). Only one pattern per line.\n.DS_Store\n# Common VCS dirs\n.git/\n.gitignore\n.bzr/\n.bzrignore\n.hg/\n.hgignore\n.svn/\n# Common backup files\n*.swp\n*.bak\n*.tmp\n*.orig\n*~\n# Various IDEs\n.project\n.idea/\n*.tmproj\n.vscode/\n"
  },
  {
    "path": "charts/housewatch/Chart.yaml",
    "content": "apiVersion: v2\nname: housewatch\ndescription: Open source tool for monitoring and managing ClickHouse clusters\ntype: application\nversion: 0.1.9\nappVersion: \"0.1.2\"\ndependencies:\n  - name: postgresql\n    version: \"12.10.0\"\n    repository: \"oci://registry-1.docker.io/bitnamicharts\"\n  - name: rabbitmq\n    version: \"12.1.3\"\n    repository: \"oci://registry-1.docker.io/bitnamicharts\"\n"
  },
  {
    "path": "charts/housewatch/templates/_helpers.tpl",
    "content": "{{/*\nExpand the name of the chart.\n*/}}\n{{- define \"housewatch.name\" -}}\n{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCreate a default fully qualified app name.\nWe truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).\nIf release name contains chart name it will be used as a full name.\n*/}}\n{{- define \"housewatch.fullname\" -}}\n{{- if .Values.fullnameOverride }}\n{{- .Values.fullnameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- $name := default .Chart.Name .Values.nameOverride }}\n{{- if contains $name .Release.Name }}\n{{- .Release.Name | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- printf \"%s-%s\" .Release.Name $name | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n{{- end }}\n{{- end }}\n\n{{/*\nCreate chart name and version as used by the chart label.\n*/}}\n{{- define \"housewatch.chart\" -}}\n{{- printf \"%s-%s\" .Chart.Name .Chart.Version | replace \"+\" \"_\" | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCommon labels\n*/}}\n{{- define \"housewatch.labels\" -}}\nhelm.sh/chart: {{ include \"housewatch.chart\" . }}\n{{ include \"housewatch.selectorLabels\" . }}\n{{- if .Chart.AppVersion }}\napp.kubernetes.io/version: {{ .Chart.AppVersion | quote }}\n{{- end }}\napp.kubernetes.io/managed-by: {{ .Release.Service }}\n{{- end }}\n\n{{/*\nSelector labels\n*/}}\n{{- define \"housewatch.selectorLabels\" -}}\napp.kubernetes.io/name: {{ include \"housewatch.name\" . }}\napp.kubernetes.io/instance: {{ .Release.Name }}\n{{- end }}\n\n{{/*\nCreate the name of the service account to use\n*/}}\n{{- define \"housewatch.serviceAccountName\" -}}\n{{- if .Values.serviceAccount.create }}\n{{- default (include \"housewatch.fullname\" .) .Values.serviceAccount.name }}\n{{- else }}\n{{- default \"default\" .Values.serviceAccount.name }}\n{{- end }}\n{{- end }}\n"
  },
  {
    "path": "charts/housewatch/templates/deployment-nginx.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"housewatch.fullname\" . }}-nginx\n  labels:\n    {{- include \"housewatch.labels\" . | nindent 4 }}\nspec:\n  selector:\n    matchLabels:\n      {{- include \"housewatch.selectorLabels\" . | nindent 6 }}\n      app.kubernetes.io/service: nginx\n  template:\n    metadata:\n      {{- with .Values.podAnnotations }}\n      annotations:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      labels:\n        {{- include \"housewatch.selectorLabels\" . | nindent 8 }}\n        app.kubernetes.io/service: nginx\n    spec:\n      {{- with .Values.imagePullSecrets }}\n      imagePullSecrets:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      volumes:\n      - name: frontend\n        emptyDir: {}\n      - name: nginx-config\n        configMap:\n          name: {{ include \"housewatch.fullname\" . }}-nginx\n      initContainers:\n      - name: frontend-copy\n        image: \"{{ .Values.image.frontendRepository }}:{{ .Values.image.tag }}\"\n        command: [sh, -cex]\n        args:\n        - cp -r /frontend/build/* /http/\n        volumeMounts:\n        - mountPath: /http\n          name: frontend\n      containers:\n        - name: nginx\n          image: \"{{ .Values.nginx.image.repository }}:{{ .Values.nginx.image.tag }}\"\n          ports:\n          - name: http\n            containerPort: 80\n            protocol: TCP\n          volumeMounts:\n          - mountPath: /http\n            name: frontend\n          - mountPath: /etc/nginx/nginx.conf\n            name: nginx-config\n            subPath: nginx.conf\n          resources:\n            {{- toYaml .Values.nginx.resources | nindent 12 }}\n"
  },
  {
    "path": "charts/housewatch/templates/deployment-web.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"housewatch.fullname\" . }}\n  labels:\n    {{- include \"housewatch.labels\" . | nindent 4 }}\nspec:\n  selector:\n    matchLabels:\n      {{- include \"housewatch.selectorLabels\" . | nindent 6 }}\n      app.kubernetes.io/service: web\n  template:\n    metadata:\n      {{- with .Values.podAnnotations }}\n      annotations:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      labels:\n        {{- include \"housewatch.selectorLabels\" . | nindent 8 }}\n        app.kubernetes.io/service: web\n    spec:\n      {{- with .Values.imagePullSecrets }}\n      imagePullSecrets:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      containers:\n        - name: web\n          image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n          command: [\"bash\", \"-c\"]\n          args:\n          - |\n              python manage.py migrate\n              python manage.py runserver 0.0.0.0:8000\n          ports:\n            - name: api\n              containerPort: 8000\n              protocol: TCP\n          env:\n          - name: REDIS_URL\n            value: redis://{{ include \"housewatch.fullname\" . }}-redis:6379\n          - name: CLICKHOUSE_HOST\n            value: \"{{ .Values.clickhouse.host }}\"\n          - name: CLICKHOUSE_DATABASE\n            value: \"{{ .Values.clickhouse.database }}\"\n          - name: CLICKHOUSE_USER\n            value: \"{{ .Values.clickhouse.user }}\"\n          - name: CLICKHOUSE_PASSWORD\n            valueFrom:\n              secretKeyRef:\n                name: \"{{ .Values.clickhouse.secretName }}\"\n                key: \"{{ .Values.clickhouse.secretPasswordKey }}\"\n          - name: CLICKHOUSE_CLUSTER\n            value: {{ .Values.clickhouse.cluster }}\n          - name: CLICKHOUSE_SECURE\n            value: \"{{ .Values.clickhouse.secure }}\"\n          - name: CLICKHOUSE_VERIFY\n            value: \"{{ .Values.clickhouse.verify }}\"\n          - name: CLICKHOUSE_CA\n            value: \"{{ .Values.clickhouse.ca }}\"\n          - name: DATABASE_URL\n            value: \"{{ . | tpl .Values.database_url }}\"\n          - name: RABBITMQ_URL\n            value: \"amqp://{{ .Values.rabbitmq.auth.username }}:{{ .Values.rabbitmq.auth.password }}@{{ .Release.Name }}-rabbitmq:5672/\" \n          livenessProbe:\n            httpGet:\n              path: /\n              port: api\n          readinessProbe:\n            httpGet:\n              path: /\n              port: api\n          resources:\n            {{- toYaml .Values.web.resources | nindent 12 }}\n"
  },
  {
    "path": "charts/housewatch/templates/deployment-worker.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"housewatch.fullname\" . }}-worker\n  labels:\n    {{- include \"housewatch.labels\" . | nindent 4 }}\nspec:\n  selector:\n    matchLabels:\n      {{- include \"housewatch.selectorLabels\" . | nindent 6 }}\n      app.kubernetes.io/service: worker\n  template:\n    metadata:\n      {{- with .Values.podAnnotations }}\n      annotations:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      labels:\n        {{- include \"housewatch.selectorLabels\" . | nindent 8 }}\n        app.kubernetes.io/service: worker\n    spec:\n      {{- with .Values.imagePullSecrets }}\n      imagePullSecrets:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      containers:\n        - name: worker\n          image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n          command: [\"./bin/celery\"]\n          env:\n          - name: REDIS_URL\n            value: redis://{{ include \"housewatch.fullname\" . }}-redis:6379\n          - name: CLICKHOUSE_HOST\n            value: \"{{ .Values.clickhouse.host }}\"\n          - name: CLICKHOUSE_DATABASE\n            value: \"{{ .Values.clickhouse.database }}\"\n          - name: CLICKHOUSE_USER\n            value: \"{{ .Values.clickhouse.user }}\"\n          - name: CLICKHOUSE_PASSWORD\n            valueFrom:\n              secretKeyRef:\n                name: \"{{ .Values.clickhouse.secretName }}\"\n                key: \"{{ .Values.clickhouse.secretPasswordKey }}\"\n          - name: CLICKHOUSE_CLUSTER\n            value: \"{{ .Values.clickhouse.cluster }}\"\n          - name: CLICKHOUSE_SECURE\n            value: \"{{ .Values.clickhouse.secure }}\"\n          - name: CLICKHOUSE_VERIFY\n            value: \"{{ .Values.clickhouse.verify }}\"\n          - name: CLICKHOUSE_CA\n            value: \"{{ .Values.clickhouse.ca }}\"\n          - name: DATABASE_URL\n            value: \"{{ . | tpl .Values.database_url }}\"\n          - name: RABBITMQ_URL\n            value: \"amqp://{{ .Values.rabbitmq.auth.username }}:{{ .Values.rabbitmq.auth.password }}@{{ .Release.Name }}-rabbitmq:5672/\" \n          resources:\n            {{- toYaml .Values.worker.resources | nindent 12 }}\n"
  },
  {
    "path": "charts/housewatch/templates/nginx-configmap.yaml",
    "content": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: {{ include \"housewatch.fullname\" . }}-nginx\ndata:\n  nginx.conf: |\n    events {\n      worker_connections 1024;\n    }\n    http {\n      include /etc/nginx/mime.types;\n      default_type application/octet-stream;\n\n      sendfile on;\n      keepalive_timeout 65;\n\n      server {\n        listen 80;\n\n        location / {\n            root /http;\n            try_files $uri $uri/ /index.html =404;\n        }\n\n        location /api {\n            proxy_pass http://{{ include \"housewatch.fullname\" . }}-api:8000;\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection 'upgrade';\n            proxy_set_header Host $host;\n        }\n\n        location /admin {\n            proxy_pass http://{{ include \"housewatch.fullname\" . }}-api:8000;\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection 'upgrade';\n            proxy_set_header Host $host;\n        }\n\n        location /healthz {\n            proxy_pass http://{{ include \"housewatch.fullname\" . }}-api:8000;\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection 'upgrade';\n            proxy_set_header Host $host;\n        }\n      }\n    }\n"
  },
  {
    "path": "charts/housewatch/templates/redis.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"housewatch.fullname\" . }}-redis\n  labels:\n    {{- include \"housewatch.labels\" . | nindent 4 }}\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      {{- include \"housewatch.selectorLabels\" . | nindent 6 }}\n      app.kubernetes.io/service: redis\n  template:\n    metadata:\n      {{- with .Values.podAnnotations }}\n      annotations:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      labels:\n        {{- include \"housewatch.selectorLabels\" . | nindent 8 }}\n        app.kubernetes.io/service: redis\n    spec:\n      containers:\n        - name: redis\n          image: \"{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}\"\n          imagePullPolicy: {{ .Values.image.pullPolicy }}\n          ports:\n            - name: redis\n              containerPort: 6379\n              protocol: TCP\n          resources:\n            {{- toYaml .Values.redis.resources | nindent 12 }}\n"
  },
  {
    "path": "charts/housewatch/templates/service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"housewatch.fullname\" . }}\n  annotations:\n    {{- toYaml .Values.service.annotations | nindent 4 }}\n  labels:\n    {{- include \"housewatch.labels\" . | nindent 4 }}\nspec:\n  type: ClusterIP\n  ports:\n    - port: 80\n      targetPort: http\n      protocol: TCP\n      name: http\n  selector:\n    {{- include \"housewatch.selectorLabels\" . | nindent 4 }}\n    app.kubernetes.io/service: nginx\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"housewatch.fullname\" . }}-api\n  labels:\n    {{- include \"housewatch.labels\" . | nindent 4 }}\nspec:\n  type: ClusterIP\n  ports:\n    - port: 8000\n      targetPort: api\n      protocol: TCP\n      name: api\n  selector:\n    {{- include \"housewatch.selectorLabels\" . | nindent 4 }}\n    app.kubernetes.io/service: web\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"housewatch.fullname\" . }}-redis\n  labels:\n    {{- include \"housewatch.labels\" . | nindent 4 }}\nspec:\n  type: ClusterIP\n  ports:\n    - port: 6379\n      targetPort: 6379\n      protocol: TCP\n      name: redis\n  selector:\n    {{- include \"housewatch.selectorLabels\" . | nindent 4 }}\n    app.kubernetes.io/service: redis\n"
  },
  {
    "path": "charts/housewatch/values.yaml",
    "content": "image:\n  repository: ghcr.io/posthog/housewatch/api\n  frontendRepository: ghcr.io/posthog/housewatch/frontend\n  tag: main\n\nnginx:\n  image:\n    repository: nginx\n    tag: stable\n\nclickhouse:\n  user: default\n  host: clickhouse\n  database: default\n  secure: \"false\"\n  verify: \"false\"\n  ca: \"\"\n\nservice:\n  annotations: {}\n\nweb:\n  resources:\n    requests:\n      cpu: 100m\n      memory: 500Mi\n    limits:\n      memory: 500Mi\n\nfrontend:\n  resources:\n    requests:\n      cpu: 500m\n      memory: 2Gi\n    limits:\n      memory: 2Gi\n\nworker:\n  resources:\n    requests:\n      cpu: 100m\n      memory: 1500Mi\n    limits:\n      memory: 1500Mi\n\ndatabase_url: postgres://housewatch:housewatch@{{ include \"postgresql.primary.fullname\" .Subcharts.postgresql }}:5432/housewatch\npostgresql:\n  auth:\n    database: housewatch\n    username: housewatch\n    password: housewatch\n\nrabbitmq:\n  auth:\n    username: housewatch\n    password: housewatch\n    erlangCookie: housewatch\n\nredis:\n  image:\n    repository: redis\n    tag: 6.2.7-alpine\n\n  resources:\n    requests:\n      cpu: 100m\n      memory: 1Gi\n    limits:\n      memory: 1Gi\n"
  },
  {
    "path": "docker/Caddyfile",
    "content": "{\n    debug\n}\n\n{$SITE_ADDRESS} {\n    reverse_proxy web:3000\n    reverse_proxy /api/* app:8000\n    reverse_proxy /logout app:8000\n    reverse_proxy /admin/ app:8000\n}\n"
  },
  {
    "path": "docker/clickhouse-server/config.d/config.xml",
    "content": "<clickhouse>\n    <!-- Listen wildcard address to allow accepting connections from other containers and host\n    network. -->\n    <listen_host>::</listen_host>\n    <listen_host>0.0.0.0</listen_host>\n    <listen_try>1</listen_try>\n\n    <!--\n       <logger>\n           <console>1</console>\n       </logger>\n       -->\n    <remote_servers>\n        <housewatch>\n            <shard>\n                <replica>\n                    <host>clickhouse</host>\n                    <port>9000</port>\n                </replica>\n            </shard>\n        </housewatch>\n    </remote_servers>\n</clickhouse>"
  },
  {
    "path": "docker-compose.dev.yml",
    "content": "version: \"3\"\n\nservices:\n  app:\n    build: .\n    environment: &django_env\n      DEBUG: 1\n      REDIS_URL: redis://redis:6379\n      RABBITMQ_URL: amqp://posthog:posthog@rabbitmq:5672\n      DATABASE_URL: postgres://housewatch:housewatch@db:5432/housewatch\n      CLICKHOUSE_HOST: clickhouse\n      CLICKHOUSE_DATABASE: default\n      CLICKHOUSE_USER: default\n      CLICKHOUSE_PASSWORD: \"\"\n      CLICKHOUSE_CLUSTER: housewatch\n      CLICKHOUSE_SECURE: false\n      CLICKHOUSE_VERIFY: false\n      CLICKHOUSE_CA: \"\"\n      AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID\n      AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY\n      AWS_DEFAULT_REGION: $AWS_DEFAULT_REGION\n    command:\n      - bash\n      - -c\n      - |\n        python manage.py runserver 0.0.0.0:8000\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - .:/code\n    depends_on:\n      migrations:\n        condition: service_completed_successfully\n      clickhouse:\n        condition: service_started\n      db:\n        condition: service_healthy\n      redis:\n        condition: service_started\n      rabbitmq:\n        condition: service_started\n\n  migrations:\n    build: .\n    environment: *django_env\n    command: python manage.py migrate\n    depends_on:\n      db:\n        condition: service_healthy\n    volumes:\n      - .:/code\n\n  web:\n    build:\n      context: ./frontend\n      dockerfile: Dockerfile.dev\n\n  caddy:\n    image: caddy:2.6.1\n    restart: unless-stopped\n    ports:\n      - \"8080:8080\"\n      - \"443:443\"\n    environment:\n      SITE_ADDRESS: \":8080\"\n    volumes:\n      - ./docker/Caddyfile:/etc/caddy/Caddyfile\n    depends_on:\n      - web\n      - app\n\n  db:\n    image: postgres:16-alpine\n    restart: on-failure\n    environment:\n      POSTGRES_USER: housewatch\n      POSTGRES_DB: housewatch\n      POSTGRES_PASSWORD: housewatch\n\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U housewatch\"]\n      interval: 5s\n      timeout: 5s\n    ports:\n      - \"5432:5432\"\n\n  redis:\n    image: redis:6.2.7-alpine\n    restart: on-failure\n    command: redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb\n\n  worker:\n    build: .\n    environment:\n      <<: *django_env\n    command:\n      - ./bin/celery\n    volumes:\n      - .:/code\n    depends_on:\n      - clickhouse\n      - db\n      - redis\n      - rabbitmq\n\n  clickhouse:\n    image: ${CLICKHOUSE_SERVER_IMAGE:-clickhouse/clickhouse-server:23.12.5}\n    restart: on-failure\n    depends_on:\n      - zookeeper\n    volumes:\n      - ./docker/clickhouse-server/config.d:/etc/clickhouse-server/config.d\n    ports:\n      - \"8123:8123\"\n\n  zookeeper:\n    image: zookeeper:3.7.0\n    restart: on-failure\n\n  rabbitmq:\n    image: rabbitmq:3.12.2-management-alpine\n    ports:\n      - \"15672:15672\" # Web management UI\n      - \"5672:5672\" # Default RabbitMQ broker port\n    environment:\n      RABBITMQ_DEFAULT_USER: posthog\n      RABBITMQ_DEFAULT_PASS: posthog\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: \"3\"\n\nservices:\n  app:\n    build: .\n    environment: &django_env\n      DATABASE_URL: postgres://housewatch:housewatch@db:5432/housewatch\n      RABBITMQ_URL: amqp://posthog:posthog@rabbitmq:5672\n      REDIS_URL: redis://redis:6379\n      CLICKHOUSE_HOST: $CLICKHOUSE_HOST\n      CLICKHOUSE_DATABASE: $CLICKHOUSE_DATABASE\n      CLICKHOUSE_USER: $CLICKHOUSE_USER\n      CLICKHOUSE_PASSWORD: $CLICKHOUSE_PASSWORD\n      CLICKHOUSE_CLUSTER: $CLICKHOUSE_CLUSTER\n      CLICKHOUSE_SECURE: $CLICKHOUSE_SECURE\n      CLICKHOUSE_VERIFY: $CLICKHOUSE_VERIFY\n      CLICKHOUSE_CA: $CLICKHOUSE_CA\n      AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID\n      AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY\n      AWS_DEFAULT_REGION: $AWS_DEFAULT_REGION\n\n    command:\n      - bash\n      - -c\n      - |\n        python manage.py migrate\n        python manage.py runserver 0.0.0.0:8000\n    volumes:\n      - .:/code\n    ports:\n      - \"8000:8000\"\n    depends_on:\n      - db\n      - redis\n      - rabbitmq\n      - clickhouse\n\n  web:\n    build: ./frontend\n    ports:\n      - \"3000:3000\"\n\n  worker:\n    build: .\n    environment:\n      <<: *django_env\n    command:\n      - ./bin/celery\n    volumes:\n      - .:/code\n    depends_on:\n      - db\n      - redis\n      - rabbitmq\n      - clickhouse\n\n  redis:\n    image: redis:6.2.7-alpine\n    restart: on-failure\n    ports:\n      - \"6388:6379\"\n    command: redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb\n\n  db:\n    image: postgres:14-alpine\n    restart: on-failure\n    environment:\n      POSTGRES_USER: housewatch\n      POSTGRES_DB: housewatch\n      POSTGRES_PASSWORD: housewatch\n\n  caddy:\n    image: caddy:2.6.1\n    restart: unless-stopped\n    ports:\n      - \"80:80\"\n      - \"443:443\"\n    environment:\n      SITE_ADDRESS: $SITE_ADDRESS\n    volumes:\n      - ./docker/Caddyfile:/etc/caddy/Caddyfile\n    depends_on:\n      - web\n      - app\n\n  rabbitmq:\n    image: rabbitmq:3.12.2-management-alpine\n    ports:\n      - \"15672:15672\" # Web management UI\n      - \"5672:5672\" # Default RabbitMQ broker port\n    environment:\n      RABBITMQ_DEFAULT_USER: posthog\n      RABBITMQ_DEFAULT_PASS: posthog\n"
  },
  {
    "path": "frontend/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# production\n/build\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n"
  },
  {
    "path": "frontend/.node-version",
    "content": "20.4.0\n"
  },
  {
    "path": "frontend/.prettierrc",
    "content": "{\n    \"trailingComma\": \"es5\",\n    \"tabWidth\": 4,\n    \"semi\": false,\n    \"singleQuote\": true,\n    \"printWidth\": 120\n}\n"
  },
  {
    "path": "frontend/Dockerfile",
    "content": "FROM alpine:latest\n\nWORKDIR /frontend\n\nCOPY build/ build/\n\nCMD [\"echo\", \"Serve the files from /frontend/build, don't run this container directly\"]"
  },
  {
    "path": "frontend/Dockerfile.dev",
    "content": "FROM node:20.4.0-alpine\n\nENV PNPM_HOME=\"/pnpm\"\nENV PATH=\"$PNPM_HOME:$PATH\"\n\nRUN corepack enable\n\nWORKDIR /frontend\n\nCOPY . .\n\nRUN pnpm i\n\nCMD [\"pnpm\", \"vite\", \"--port\", \"3000\", \"--host\"]\n"
  },
  {
    "path": "frontend/README.md",
    "content": "# Getting Started with Create React App\n\nThis project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).\n\n## Available Scripts\n\nIn the project directory, you can run:\n\n### `npm start`\n\nRuns the app in the development mode.\\\nOpen [http://localhost:3000](http://localhost:3000) to view it in the browser.\n\nThe page will reload if you make edits.\\\nYou will also see any lint errors in the console.\n\n### `npm test`\n\nLaunches the test runner in the interactive watch mode.\\\nSee the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.\n\n### `npm run build`\n\nBuilds the app for production to the `build` folder.\\\nIt correctly bundles React in production mode and optimizes the build for the best performance.\n\nThe build is minified and the filenames include the hashes.\\\nYour app is ready to be deployed!\n\nSee the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.\n\n### `npm run eject`\n\n**Note: this is a one-way operation. Once you `eject`, you can’t go back!**\n\nIf you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.\n\nInstead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.\n\nYou don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.\n\n## Learn More\n\nYou can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).\n\nTo learn React, check out the [React documentation](https://reactjs.org/).\n"
  },
  {
    "path": "frontend/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"utf-8\" />\n        <link rel=\"icon\" />\n        <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicons/favicon.ico\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <meta name=\"theme-color\" content=\"#000000\" />\n        <meta name=\"description\" content=\"Web site created using create-react-app\" />\n        <title>HouseWatch</title>\n    </head>\n    <body>\n        <noscript>You need to enable JavaScript to run this app.</noscript>\n        <div id=\"root\"></div>\n        <script type=\"module\" src=\"/src/index.tsx\"></script>\n    </body>\n</html>\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n    \"name\": \"housewatch\",\n    \"version\": \"1.0.0-beta\",\n    \"dependencies\": {\n        \"@ant-design/charts\": \"^1.4.2\",\n        \"@ant-design/icons\": \"^5.2.0\",\n        \"@ant-design/plots\": \"^2.1.12\",\n        \"antd\": \"^5.13.2\",\n        \"prismjs\": \"^1.29.0\",\n        \"react\": \"^18.2.0\",\n        \"react-dom\": \"^18.2.0\",\n        \"react-router-dom\": \"5.2.0\",\n        \"react-scripts\": \"^2.1.3\",\n        \"react-simple-code-editor\": \"^0.13.1\",\n        \"sql-formatter-plus\": \"^1.3.6\",\n        \"swr\": \"^2.2.4\",\n        \"typescript\": \"^4.9.5\",\n        \"uuid\": \"^9.0.0\"\n    },\n    \"devDependencies\": {\n        \"@types/jest\": \"^27.5.2\",\n        \"@types/lodash\": \"^4.14.192\",\n        \"@types/node\": \"^16.18.22\",\n        \"@types/react\": \"^18.2.21\",\n        \"@types/react-dom\": \"^18.2.7\",\n        \"@types/react-router-dom\": \"5.3.3\",\n        \"@types/uuid\": \"^9.0.1\",\n        \"@vitejs/plugin-react\": \"^4.2.1\",\n        \"vite\": \"^5.0.12\"\n    },\n    \"pnpm\": {\n        \"overrides\": {\n            \"node-forge\": \"1.3.2\"\n        }\n    },\n    \"scripts\": {\n        \"dev\": \"vite\",\n        \"build\": \"tsc && vite build\",\n        \"lint\": \"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0\",\n        \"preview\": \"vite preview\"\n    },\n    \"eslintConfig\": {\n        \"extends\": [\n            \"react-app\",\n            \"react-app/jest\"\n        ]\n    },\n    \"proxy\": \"http://localhost:8000\",\n    \"browserslist\": {\n        \"production\": [\n            \">0.2%\",\n            \"not dead\",\n            \"not op_mini all\"\n        ],\n        \"development\": [\n            \"last 1 chrome version\",\n            \"last 1 firefox version\",\n            \"last 1 safari version\"\n        ]\n    },\n    \"packageManager\": \"pnpm@9.15.9+sha512.68046141893c66fad01c079231128e9afb89ef87e2691d69e4d40eee228988295fd4682181bae55b58418c3a253bde65a505ec7c5f9403ece5cc3cd37dcf2531\"\n}\n"
  },
  {
    "path": "frontend/public/fonts/OFL.txt",
    "content": "Copyright (c) 2015 Indian Type Foundry (info@indiantypefoundry.com)\r\n\r\nThis Font Software is licensed under the SIL Open Font License, Version 1.1.\r\nThis license is copied below, and is also available with a FAQ at:\r\nhttp://scripts.sil.org/OFL\r\n\r\n\r\n-----------------------------------------------------------\r\nSIL OPEN FONT LICENSE Version 1.1 - 26 February 2007\r\n-----------------------------------------------------------\r\n\r\nPREAMBLE\r\nThe goals of the Open Font License (OFL) are to stimulate worldwide\r\ndevelopment of collaborative font projects, to support the font creation\r\nefforts of academic and linguistic communities, and to provide a free and\r\nopen framework in which fonts may be shared and improved in partnership\r\nwith others.\r\n\r\nThe OFL allows the licensed fonts to be used, studied, modified and\r\nredistributed freely as long as they are not sold by themselves. The\r\nfonts, including any derivative works, can be bundled, embedded,\r\nredistributed and/or sold with any software provided that any reserved\r\nnames are not used by derivative works. The fonts and derivatives,\r\nhowever, cannot be released under any other type of license. The\r\nrequirement for fonts to remain under this license does not apply\r\nto any document created using the fonts or their derivatives.\r\n\r\nDEFINITIONS\r\n\"Font Software\" refers to the set of files released by the Copyright\r\nHolder(s) under this license and clearly marked as such. This may\r\ninclude source files, build scripts and documentation.\r\n\r\n\"Reserved Font Name\" refers to any names specified as such after the\r\ncopyright statement(s).\r\n\r\n\"Original Version\" refers to the collection of Font Software components as\r\ndistributed by the Copyright Holder(s).\r\n\r\n\"Modified Version\" refers to any derivative made by adding to, deleting,\r\nor substituting -- in part or in whole -- any of the components of the\r\nOriginal Version, by changing formats or by porting the Font Software to a\r\nnew environment.\r\n\r\n\"Author\" refers to any designer, engineer, programmer, technical\r\nwriter or other person who contributed to the Font Software.\r\n\r\nPERMISSION & CONDITIONS\r\nPermission is hereby granted, free of charge, to any person obtaining\r\na copy of the Font Software, to use, study, copy, merge, embed, modify,\r\nredistribute, and sell modified and unmodified copies of the Font\r\nSoftware, subject to the following conditions:\r\n\r\n1) Neither the Font Software nor any of its individual components,\r\nin Original or Modified Versions, may be sold by itself.\r\n\r\n2) Original or Modified Versions of the Font Software may be bundled,\r\nredistributed and/or sold with any software, provided that each copy\r\ncontains the above copyright notice and this license. These can be\r\nincluded either as stand-alone text files, human-readable headers or\r\nin the appropriate machine-readable metadata fields within text or\r\nbinary files as long as those fields can be easily viewed by the user.\r\n\r\n3) No Modified Version of the Font Software may use the Reserved Font\r\nName(s) unless explicit written permission is granted by the corresponding\r\nCopyright Holder. This restriction only applies to the primary font name as\r\npresented to the users.\r\n\r\n4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font\r\nSoftware shall not be used to promote, endorse or advertise any\r\nModified Version, except to acknowledge the contribution(s) of the\r\nCopyright Holder(s) and the Author(s) or with their explicit written\r\npermission.\r\n\r\n5) The Font Software, modified or unmodified, in part or in whole,\r\nmust be distributed entirely under this license, and must not be\r\ndistributed under any other license. The requirement for fonts to\r\nremain under this license does not apply to any document created\r\nusing the Font Software.\r\n\r\nTERMINATION\r\nThis license becomes null and void if any of the above conditions are\r\nnot met.\r\n\r\nDISCLAIMER\r\nTHE FONT SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\r\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF\r\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\r\nOF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE\r\nCOPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\r\nINCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL\r\nDAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\r\nFROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM\r\nOTHER DEALINGS IN THE FONT SOFTWARE.\r\n"
  },
  {
    "path": "frontend/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"utf-8\" />\n        <link rel=\"icon\" />\n        <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicons/favicon.ico\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <meta name=\"theme-color\" content=\"#000000\" />\n        <meta name=\"description\" content=\"Web site created using create-react-app\" />\n        <link rel=\"apple-touch-icon\" href=\"%PUBLIC_URL%/logo192.png\" />\n        <link rel=\"manifest\" href=\"%PUBLIC_URL%/manifest.json\" />\n        <title>HouseWatch</title>\n    </head>\n    <body>\n        <noscript>You need to enable JavaScript to run this app.</noscript>\n        <div id=\"root\"></div>\n    </body>\n</html>\n"
  },
  {
    "path": "frontend/public/manifest.json",
    "content": "{\n    \"short_name\": \"HouseWatch\",\n    \"name\": \"HouseWatch\",\n    \"icons\": [],\n    \"start_url\": \".\",\n    \"display\": \"standalone\",\n    \"theme_color\": \"#000000\",\n    \"background_color\": \"#ffffff\"\n}\n"
  },
  {
    "path": "frontend/public/robots.txt",
    "content": "# https://www.robotstxt.org/robotstxt.html\nUser-agent: *\nDisallow:\n"
  },
  {
    "path": "frontend/src/App.tsx",
    "content": "import React from 'react'\nimport { BrowserRouter as Router } from 'react-router-dom'\nimport Layout from './Layout'\n\nfunction App() {\n    return (\n        <div className=\"App\">\n            <Router>\n                <Layout />\n            </Router>\n        </div>\n    )\n}\n\nexport default App\n"
  },
  {
    "path": "frontend/src/Layout.tsx",
    "content": "// @ts-nocheck\nimport React, { useEffect, useState } from 'react'\nimport { DiskUsage } from './pages/DiskUsage/DiskUsage'\nimport SlowQueries from './pages/SlowQueries/SlowQueries'\nimport Schema from './pages/SchemaStats/SchemaStats'\nimport QueryDetail from './pages/SlowQueries/QueryDetail'\nimport SchemaTable from './pages/SchemaStats/SchemaTable'\nimport Overview from './pages/Overview/Overview'\nimport Clusters from './pages/Clusters/Clusters'\nimport Backups from './pages/Backups/Backups'\nimport ScheduledBackups from './pages/Backups/ScheduledBackups'\nimport Errors from './pages/Errors/Errors'\nimport { Switch, Route, useHistory } from 'react-router-dom'\n\nimport { Operations } from './pages/Operations/Operations'\nimport RunningQueries from './pages/RunningQueries/RunningQueries'\nimport Logs from './pages/Logs/Logs'\nimport {\n    ApartmentOutlined,\n    CloudServerOutlined,\n    CodeOutlined,\n    DashboardOutlined,\n    HddOutlined,\n    HomeOutlined,\n    WarningOutlined,\n    ClockCircleOutlined,\n    GithubFilled,\n    BarsOutlined,\n    FormOutlined,\n    ToolOutlined,\n    SaveOutlined,\n} from '@ant-design/icons'\nimport { ConfigProvider, MenuProps } from 'antd'\nimport { Layout, Menu } from 'antd'\nimport QueryEditorPage from './pages/QueryEditor/QueryEditorPage'\nimport AIToolsPage from './pages/AITools/AIToolsPage'\n\nconst { Header, Content, Footer, Sider } = Layout\n\ntype MenuItem = Required<MenuProps>['items'][number]\n\nconst items: MenuItem[] = [\n    { key: '', icon: <HomeOutlined />, label: 'Overview' },\n    { key: 'clusters', label: 'Clusters', icon: <CloudServerOutlined /> },\n    {\n        key: 'backup',\n        label: 'Backup',\n        icon: <SaveOutlined />,\n        children: [\n            { key: 'backups', label: 'Adhoc Backups' },\n            { key: 'scheduled_backups', label: 'Scheduled Backups' },\n        ],\n    },\n    { key: 'query_performance', label: 'Query performance', icon: <ClockCircleOutlined /> },\n    { key: 'running_queries', label: 'Running queries', icon: <DashboardOutlined /> },\n    { key: 'schema', label: 'Schema stats', icon: <HddOutlined /> },\n    { key: 'disk_usage', label: 'Disk usage', icon: <ApartmentOutlined /> },\n    { key: 'logs', label: 'Logs', icon: <BarsOutlined /> },\n    { key: 'errors', label: 'Errors', icon: <WarningOutlined /> },\n    { key: 'query_editor', label: 'Query editor', icon: <FormOutlined /> },\n    { key: 'operations', label: 'Operations', icon: <CodeOutlined /> },\n    { key: 'ai_tools', label: 'AI Tools', icon: <ToolOutlined /> },\n]\n\nexport default function AppLayout(): JSX.Element {\n    const [hostname, setHostname] = useState('')\n\n    const fetchHostname = async () => {\n        const response = await fetch(`/api/analyze/hostname`)\n        const responseJson = await response.json()\n        setHostname(responseJson.hostname)\n    }\n\n    useEffect(() => {\n        fetchHostname()\n    }, [])\n\n    const history = useHistory()\n    const openPage = history.location.pathname.split('/')[1]\n\n    return (\n        <ConfigProvider theme={{ token: { colorPrimary: '#ffb200', colorPrimaryBg: 'black' } }}>\n            <Layout style={{ minHeight: '100vh' }}>\n                <Sider className=\"sidebar\">\n                    <div className=\"clickable\" onClick={() => history.push('')}>\n                        <h1\n                            style={{ fontSize: 20, color: '#ffb200', textAlign: 'center', fontFamily: 'Hind Siliguri' }}\n                        >\n                            HouseWatch\n                        </h1>\n                    </div>\n                    <Menu\n                        defaultSelectedKeys={[openPage]}\n                        theme=\"dark\"\n                        mode=\"inline\"\n                        items={items}\n                        onClick={(info) => history.push(`/${info.key}`)}\n                    />\n                </Sider>\n                <Layout>\n                    <Header\n                        style={{\n                            background: 'rgb(231 231 231)',\n                            borderBottom: '1px solid #c7c7c7',\n                            display: 'inline-block',\n                        }}\n                    >\n                        <p style={{ textAlign: 'center', margin: 0 }}>\n                            <b>{hostname}</b>\n                        </p>\n                    </Header>\n\n                    <Content style={{ margin: 'auto', display: 'block', width: '85%', marginTop: 20 }}>\n                        <Switch>\n                            <Route exact path=\"/\" component={Overview}></Route>\n                            <Route exact path=\"/clusters\" component={Clusters}></Route>\n                            <Route exact path=\"/backups\" component={Backups}></Route>\n                            <Route exact path=\"/scheduled_backups\" component={ScheduledBackups}></Route>\n                            <Route exact path=\"/disk_usage\">\n                                <DiskUsage />\n                            </Route>\n                            <Route exact path=\"/query_performance\" component={SlowQueries}></Route>\n                            <Route exact path=\"/schema\" component={Schema}></Route>\n                            <Route exact path=\"/schema/:table\" component={SchemaTable}></Route>\n\n                            <Route exact path=\"/query_performance/:query_hash\" component={QueryDetail}></Route>\n                            <Route exact path=\"/operations\" component={Operations}></Route>\n                            <Route exact path=\"/running_queries\" component={RunningQueries}></Route>\n                            <Route exact path=\"/logs\" component={Logs}></Route>\n                            <Route exact path=\"/errors\" component={Errors}></Route>\n                            <Route exact path=\"/query_editor\" component={QueryEditorPage}></Route>\n                            <Route exact path=\"/query_editor/:tab\" component={QueryEditorPage}></Route>\n                            <Route exact path=\"/query_editor/:tab/:id\" component={QueryEditorPage}></Route>\n                            <Route exact path=\"/ai_tools\" component={AIToolsPage}></Route>\n                        </Switch>\n                    </Content>\n                    <Footer style={{ textAlign: 'center' }}>\n                        <p style={{ lineHeight: 2 }}>\n                            Created by{' '}\n                            <a href=\"https://posthog.com\" target=\"_blank\" rel=\"noopener noreferrer\">\n                                PostHog\n                            </a>\n                        </p>\n                        <a\n                            href=\"https://github.com/PostHog/HouseWatch\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            style={{ color: 'black' }}\n                        >\n                            <GithubFilled />\n                        </a>\n                    </Footer>\n                </Layout>\n            </Layout>\n        </ConfigProvider>\n    )\n}\n"
  },
  {
    "path": "frontend/src/index.css",
    "content": "@font-face {\n    font-family: 'Hind Siliguri';\n    src: url('fonts/HindSiliguri-Medium.ttf');\n}\n\nhtml {\n    overflow: hidden;\n    height: 100%;\n}\n\nbody {\n    margin: 0;\n    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',\n        'Droid Sans', 'Helvetica Neue', sans-serif;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n    color: #262626;\n    font-size: 0.875rem;\n    line-height: 1.57;\n    font-weight: 400;\n    overflow: auto;\n    height: 100%;\n}\n\n.ant-menu-item-selected {\n    color: black !important;\n}\n\nh1 {\n    font-size: 24px;\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n    font-family: sans-serif;\n}\n\nstrong,\nb {\n    font-weight: 600;\n}\n\ncode {\n    font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;\n}\n\n.clickable:hover,\n.clickable div:hover,\n.code-editor:hover textarea {\n    cursor: pointer !important;\n}\n\n.code-editor textarea {\n    line-height: 2 !important;\n    font-weight: 100 !important;\n}\n\n.code-editor:hover {\n    background-color: #ededed;\n}\n\n.ant-tabs-tab-active .ant-tabs-tab-btn,\n.ant-tabs-tab-btn:hover {\n    color: #1677ff !important;\n}\n\n.ant-tabs-ink-bar {\n    background: #1677ff !important;\n}\n\n.run-async-migration-btn:hover {\n    border-color: #64a0f4 !important;\n    background: #13a5ff !important;\n}\n\ninput,\ntextarea,\nbutton,\nselect,\n.ant-select-selector {\n    box-shadow: none !important;\n}\n\n.sidebar .ant-layout-sider-children {\n    position: fixed;\n}\n\n.ant-select-item-option-selected:not(.ant-select-item-option-disabled) {\n    background-color: initial !important;\n}\n"
  },
  {
    "path": "frontend/src/index.tsx",
    "content": "import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport './index.css'\nimport App from './App'\n\nconst root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)\nroot.render(<App />)\n"
  },
  {
    "path": "frontend/src/pages/AITools/AIToolsPage.tsx",
    "content": "import React, { useEffect, useState } from 'react'\nimport { Tabs } from 'antd'\nimport { useHistory } from 'react-router-dom'\nimport NaturalLanguageQueryEditor from './NaturalLanguageQueryEditor'\n\nexport default function AIToolsPage() {\n    const history = useHistory()\n    const [error, setError] = useState<string | null>(null)\n\n    const loadData = async () => {\n        const res = await fetch('/api/analyze/ai_tools_available')\n        const resJson = await res.json()\n        if ('error' in resJson) {\n            setError(resJson['error'])\n        }\n    }\n\n    useEffect(() => {\n        loadData()\n    }, [])\n\n    return (\n        <>\n            <h1>AI Tools (Alpha)</h1>\n            {error ? (\n                <p style={{ marginTop: 50 }}>{error}</p>\n            ) : (\n                <>\n                    <Tabs\n                        items={[\n                            {\n                                key: 'natural_language',\n                                label: `Natural language query editor`,\n                                children: <NaturalLanguageQueryEditor />,\n                            },\n                        ]}\n                        defaultActiveKey=\"natural_language\"\n                        onChange={(tab) => history.push(`/query_editor/${tab}`)}\n                    />\n                </>\n            )}\n        </>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/AITools/NaturalLanguageQueryEditor.tsx",
    "content": "import React, { useEffect, useState } from 'react'\nimport { Select, Checkbox, Button, Table, ConfigProvider } from 'antd'\nimport TextArea from 'antd/es/input/TextArea'\n// @ts-ignore\nimport { highlight, languages } from 'prismjs/components/prism-core' // @ts-ignore\nimport 'prismjs/components/prism-sql'\nimport 'prismjs/themes/prism.css'\nimport Editor from 'react-simple-code-editor'\n// @ts-ignore\nimport { format } from 'sql-formatter-plus'\n\nexport interface TableData {\n    table: string\n    database: string\n}\n\nexport default function NaturalLanguageQueryEditor() {\n    const [query, setQuery] = useState('')\n    const [tables, setTables] = useState<TableData[] | null>(null)\n    const [tablesToQuery, setTablesToQuery] = useState([])\n    const [readonly, setReadonly] = useState(true)\n    const [data, setData] = useState([{}])\n    const [sql, setSql] = useState<string | null>(null)\n    const [loading, setLoading] = useState(false)\n    const [error, setError] = useState('')\n\n    const columns = data.length > 0 ? Object.keys(data[0]).map((column) => ({ title: column, dataIndex: column })) : []\n\n    const runQuery = async () => {\n        setLoading(true)\n        setSql(null)\n        const res = await fetch('/api/analyze/natural_language_query', {\n            method: 'POST',\n            headers: {\n                'Content-Type': 'application/json',\n            },\n            body: JSON.stringify({\n                query: query,\n                tables_to_query: tablesToQuery,\n                readonly: readonly || false,\n            }),\n        })\n        const resJson = await res.json()\n        if (resJson.error) {\n            setError(resJson.error)\n            setData([])\n        } else {\n            setData(resJson.result)\n        }\n        setSql(resJson.sql)\n        setLoading(false)\n    }\n    const loadTableData = async () => {\n        const res = await fetch('/api/analyze/tables')\n        const resJson = await res.json()\n        setTables(resJson)\n    }\n\n    useEffect(() => {\n        loadTableData()\n    }, [])\n\n    const selectOptions = (tables || []).map((t) => ({\n        value: [t.database, t.table].join('>>>>>'),\n        label: [t.database, t.table].join('.'),\n    }))\n\n    return (\n        <>\n            <div id=\"nl-form\">\n                <div>\n                    <p>Select the tables you'd like to query:</p>\n                    <Select\n                        placeholder=\"system.query_log\"\n                        optionFilterProp=\"children\"\n                        options={selectOptions}\n                        style={{ width: 600 }}\n                        onChange={(value) => {\n                            setTablesToQuery(value)\n                        }}\n                        mode=\"multiple\"\n                        showSearch={false}\n                    />\n                </div>\n                <br />\n                <div>\n                    <Checkbox checked={readonly} onChange={(e) => setReadonly(e.target.value)}>\n                        Read-only\n                    </Checkbox>\n                </div>\n                <br />\n                <div>\n                    <p>Describe what you'd like to query (the more specific the better):</p>\n                    <TextArea id=\"nl-query-textarea\" onChange={(e) => setQuery(e.target.value)} placeholder='give me the 10 slowest queries over the last hour and their memory usage in gb' />\n                </div>\n                <br />\n                <Button\n                    type=\"primary\"\n                    style={{ width: '100%', boxShadow: 'none' }}\n                    onClick={runQuery}\n                    disabled={loading || tablesToQuery.length < 1 || !query}\n                >\n                    Run\n                </Button>\n            </div>\n            <br />\n            {sql ? (\n                <details>\n                    <summary style={{ color: '#1677ff', cursor: 'pointer' }}>Show SQL</summary>\n                    <br />\n                    <Editor\n                        value={format(sql)}\n                        onValueChange={() => {}}\n                        highlight={(code) => highlight(code, languages.sql)}\n                        padding={10}\n                        style={{\n                            fontFamily: '\"Fira code\", \"Fira Mono\", monospace',\n                            fontSize: 16,\n                            border: '1px solid rgb(216, 216, 216)',\n                            borderRadius: 4,\n                            boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)',\n                            marginBottom: 5,\n                        }}\n                        disabled\n                        className=\"code-editor\"\n                    />\n                </details>\n            ) : null}\n\n            <br />\n            <ConfigProvider renderEmpty={() => <p style={{ color: '#c40000', fontFamily: 'monospace' }}>{error}</p>}>\n                <Table columns={columns} dataSource={data} scroll={{ x: 400 }} loading={loading} />\n            </ConfigProvider>\n        </>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/Backups/Backups.tsx",
    "content": "import React, { useEffect, useState } from 'react'\nimport { usePollingEffect } from '../../utils/usePollingEffect'\nimport { ColumnType } from 'antd/es/table'\nimport { Table, Button, Form, Input, Checkbox, Modal, Tag, Col, Progress, Row, Tooltip, notification } from 'antd'\nimport useSWR, { mutate } from 'swr'\n\ninterface BackupRow {\n    id: string\n    name: string\n    status: string\n    error: string\n    start_time: string\n    end_time: string\n    num_files: number\n    total_size: number\n    num_entries: number\n    uncompressed_size: number\n    compressed_size: number\n    files_read: number\n    bytes_read: number\n}\n\ninterface Backups {\n    backups: BackupRow[]\n}\n\ntype FieldType = {\n    database?: string\n    table?: string\n    bucket?: string\n    path?: string\n    is_sharded?: boolean\n    aws_access_key_id?: string\n    aws_secret_access_key?: string\n}\n\nexport default function Backups() {\n    const [open, setOpen] = useState(false)\n    const [confirmLoading, setConfirmLoading] = useState(false)\n\n    const [form] = Form.useForm() // Hook to get form API\n\n    const handleSubmit = async () => {\n        try {\n            // Validate and get form values\n            const values = await form.validateFields()\n            setConfirmLoading(true)\n            const res = await fetch(`/api/backups`, {\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/json',\n                },\n                body: JSON.stringify(values),\n            })\n            setOpen(false)\n            setConfirmLoading(false)\n            mutate('/api/backups')\n            return await res.json()\n        } catch (error) {\n            notification.error({\n                message: 'Creating backup failed',\n            })\n        }\n    }\n\n    const showModal = () => {\n        setOpen(true)\n    }\n    const handleCancel = () => {\n        console.log('Clicked cancel button')\n        setOpen(false)\n    }\n\n    const loadData = async (url: string) => {\n        try {\n            const res = await fetch(url)\n            const resJson = await res.json()\n            const backups = { backups: resJson }\n            return backups\n        } catch (err) {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n\n    const { data, error, isLoading } = useSWR('/api/backups', loadData)\n\n    const columns: ColumnType<BackupRow>[] = [\n        { title: 'UUID', dataIndex: 'id' },\n        { title: 'Name', dataIndex: 'name' },\n        {\n            title: 'Status',\n            dataIndex: 'status',\n            render: (_, { status }) => {\n                var color = 'volcano'\n                switch (status) {\n                    case 'CREATING_BACKUP' || 'RESTORING':\n                        color = 'black'\n                        break\n                    case 'BACKUP_CREATED' || 'RESTORED':\n                        color = 'green'\n                        break\n                    case 'BACKUP_FAILED' || 'RESTORE_FAILED':\n                        color = 'volcano'\n                        break\n                }\n                return (\n                    <Tag color={color} key={status}>\n                        {status.toUpperCase()}\n                    </Tag>\n                )\n            },\n        },\n        { title: 'Error', dataIndex: 'error' },\n        { title: 'Start', dataIndex: 'start_time' },\n        { title: 'End', dataIndex: 'end_time' },\n        { title: 'Size', dataIndex: 'total_size' },\n    ]\n\n    usePollingEffect(\n        async () => {\n            mutate('/api/backups')\n        },\n        [],\n        { interval: 5000 }\n    )\n\n    return isLoading ? (\n        <div>loading...</div>\n    ) : error ? (\n        <div>error</div>\n    ) : (\n        <div>\n            <h1 style={{ textAlign: 'left' }}>Backups</h1>\n            <Button onClick={showModal}>Create Backup</Button>\n            <Modal\n                title=\"Create Backup\"\n                open={open}\n                onOk={handleSubmit}\n                confirmLoading={confirmLoading}\n                onCancel={handleCancel}\n            >\n                <Form\n                    name=\"basic\"\n                    form={form}\n                    labelCol={{ span: 8 }}\n                    wrapperCol={{ span: 16 }}\n                    style={{ maxWidth: 600 }}\n                    initialValues={{ remember: true }}\n                    autoComplete=\"on\"\n                >\n                    <Form.Item<FieldType>\n                        label=\"Database\"\n                        name=\"database\"\n                        initialValue=\"default\"\n                        rules={[{ required: true, message: 'Please select a database to back up from' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"Table\"\n                        name=\"table\"\n                        initialValue=\"test_backup\"\n                        rules={[{ required: true, message: 'Please select a table to back up' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"Is Sharded\"\n                        name=\"is_sharded\"\n                        initialValue=\"false\"\n                        valuePropName=\"checked\"\n                        rules={[{ required: true, message: 'Is this table sharded?' }]}\n                    >\n                        <Checkbox defaultChecked={false} />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"S3 Bucket\"\n                        name=\"bucket\"\n                        initialValue=\"posthog-clickhouse\"\n                        rules={[{ required: true, message: 'What S3 bucket to backup into' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"S3 Path\"\n                        name=\"path\"\n                        initialValue=\"testing/test_backup/7\"\n                        rules={[{ required: true, message: 'What is the path in the bucket to backup to' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n                    <Form.Item<FieldType>\n                        label=\"AWS Access Key ID\"\n                        name=\"aws_access_key_id\"\n                        initialValue=\"AKIAIOSFODNN7EXAMPLE\"\n                        rules={[{ required: false, message: 'AWS Access Key ID to use for access to the S3 bucket' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"AWS Secret Access Key\"\n                        name=\"aws_secret_access_key\"\n                        initialValue=\"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\"\n                        rules={[{ required: false, message: 'AWS Secret Access Key used to access S3 bucket' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n                </Form>\n            </Modal>\n            <Table columns={columns} dataSource={data!.backups} loading={isLoading} />\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/Backups/ScheduledBackups.tsx",
    "content": "import React, { useEffect, useState } from 'react'\nimport { usePollingEffect } from '../../utils/usePollingEffect'\nimport { ColumnType } from 'antd/es/table'\nimport {\n    Switch,\n    Select,\n    Table,\n    Button,\n    Form,\n    Input,\n    Checkbox,\n    Modal,\n    Tag,\n    Col,\n    Progress,\n    Row,\n    Tooltip,\n    notification,\n} from 'antd'\nimport DeleteOutlined from '@ant-design/icons/DeleteOutlined'\nimport EditOutlined from '@ant-design/icons/EditOutlined'\nimport { Clusters } from '../Clusters/Clusters'\nimport useSWR, { mutate } from 'swr'\n\ninterface ScheduleRow {\n    id: string\n    created_at: string\n    enabled: boolean\n    last_run_time: string\n    cluster: string\n    schedule: string\n    incremental_schedule: string\n    table: string\n    is_sharded: boolean\n    database: string\n    bucket: string\n    path: string\n    last_run: string\n}\n\ninterface Backups {\n    backups: ScheduleRow[]\n}\n\ntype FieldType = {\n    cluster?: string\n    schedule?: string\n    incremental_schedule?: string\n    database?: string\n    table?: string\n    is_sharded?: boolean\n    bucket?: string\n    path?: string\n    aws_access_key_id?: string\n    aws_secret_access_key?: string\n}\n\nexport default function ScheduledBackups() {\n    const [open, setOpen] = useState(false)\n    const [confirmLoading, setConfirmLoading] = useState(false)\n\n    const [form] = Form.useForm() // Hook to get form API\n\n    const [editingRow, setEditingRow] = useState<ScheduleRow | null>(null) // <-- New state to hold the editing row data\n\n    const handleSubmit = async () => {\n        try {\n            const method = editingRow ? 'PATCH' : 'POST'\n            const url = editingRow ? `/api/scheduled_backups/${editingRow.id}` : '/api/scheduled_backups'\n            const values = await form.validateFields()\n            setConfirmLoading(true)\n            const res = await fetch(url, {\n                method: method,\n                headers: {\n                    'Content-Type': 'application/json',\n                },\n                body: JSON.stringify(values),\n            })\n            setOpen(false)\n            setConfirmLoading(false)\n            setEditingRow(null)\n            mutate('/api/scheduled_backups')\n            return await res.json()\n        } catch (error) {\n            notification.error({\n                message: 'Creating backup failed',\n            })\n        }\n    }\n\n    const handleCancel = () => {\n        console.log('Clicked cancel button')\n        setOpen(false)\n        form.resetFields()\n        setEditingRow(null)\n    }\n\n    const showModal = (rowData: ScheduleRow | null = null) => {\n        setEditingRow(rowData)\n        if (rowData) {\n            form.setFieldsValue(rowData)\n        } else {\n            form.resetFields()\n        }\n        setOpen(true)\n    }\n\n    const handleEdit = (rowData: ScheduleRow) => {\n        showModal(rowData)\n    }\n\n    const fetchBackups = async (url: string) => {\n        try {\n            const res = await fetch(url)\n            const resJson = await res.json()\n            const backups = { backups: resJson.results }\n            return backups\n        } catch (err) {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n    const fetchClusters = async (url: string) => {\n        try {\n            const res = await fetch(url)\n            const resJson = await res.json()\n            const clusters = { clusters: resJson }\n            return clusters\n        } catch (err) {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n\n    const { data: backups, error: backupsError, isLoading: backupsIsLoading } = useSWR(\n        '/api/scheduled_backups',\n        fetchBackups\n    )\n    const { data: clusters, error: clustersError, isLoading: clustersIsLoading } = useSWR(\n        '/api/clusters',\n        fetchClusters\n    )\n\n    const columns: ColumnType<ScheduleRow>[] = [\n        {\n            title: 'Enabled',\n            dataIndex: 'enabled',\n            render: (_, sched) => {\n                const toggleEnabled = async () => {\n                    try {\n                        const res = await fetch(`/api/scheduled_backups/${sched.id}`, {\n                            method: 'PATCH',\n                            headers: {\n                                'Content-Type': 'application/json',\n                            },\n                            body: JSON.stringify({ enabled: !sched.enabled }),\n                        })\n                        mutate('/api/scheduled_backups')\n                        return await res.json()\n                    } catch (error) {\n                        notification.error({\n                            message: 'Failed to toggle backup',\n                        })\n                    }\n                }\n                return <Switch defaultChecked={sched.enabled} onChange={toggleEnabled} />\n            },\n        },\n        { title: 'Cluster', dataIndex: 'cluster' },\n        { title: 'Schedule', dataIndex: 'schedule' },\n        { title: 'Incremental Schedule', dataIndex: 'incremental_schedule' },\n        { title: 'Last Run Time', dataIndex: 'last_run_time' },\n        { title: 'Database', dataIndex: 'database' },\n        { title: 'Table', dataIndex: 'table' },\n        { title: 'Is Sharded', dataIndex: 'is_sharded', render: (_, sched) => (sched.is_sharded ? 'Yes' : 'No') },\n        { title: 'S3 Location', dataIndex: 'bucket', render: (_, sched) => 's3://' + sched.bucket + '/' + sched.path },\n        {\n            title: 'Actions',\n            dataIndex: 'id',\n            render: (id: string, rowData: ScheduleRow) => {\n                const deleteBackup = async () => {\n                    try {\n                        const res = await fetch(`/api/scheduled_backups/${id}`, {\n                            method: 'DELETE',\n                        })\n                        mutate('/api/scheduled_backups')\n                        return await res.text()\n                    } catch (error) {\n                        notification.error({\n                            message: 'Failed to delete backup',\n                        })\n                    }\n                }\n\n                return (\n                    <>\n                        <EditOutlined onClick={() => handleEdit(rowData)} />\n                        <DeleteOutlined onClick={() => deleteBackup()} style={{ marginLeft: '15px' }} />\n                    </>\n                )\n            },\n        },\n    ]\n\n    usePollingEffect(\n        async () => {\n            mutate('/api/scheduled_backups')\n        },\n        [],\n        { interval: 5000 }\n    )\n\n    const default_form_values = {\n        schedule: '0 0 * * *',\n        incremental_schedule: '0 0 * * *',\n        database: 'default',\n        table: 'test_backup',\n        bucket: 'posthog-clickhouse',\n        path: 'testing/test_backup/7',\n        aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE',\n        aws_secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',\n    }\n    return backupsIsLoading || clustersIsLoading ? (\n        <div>loading...</div>\n    ) : backupsError || clustersError ? (\n        <div>error</div>\n    ) : (\n        <div>\n            <h1 style={{ textAlign: 'left' }}>Scheduled Backups</h1>\n            <p style={{ textAlign: 'left' }}>\n                It's a bit of a hack, but if you want to backup a database just omit the table when you create the\n                scheduled backup.\n            </p>\n            <Button onClick={() => showModal()}>Create Backup</Button>\n            <Modal\n                title={editingRow ? 'Edit Backup' : 'Create Backup'}\n                open={open}\n                onOk={handleSubmit}\n                confirmLoading={confirmLoading}\n                onCancel={handleCancel}\n            >\n                <Form\n                    name=\"basic\"\n                    form={form}\n                    labelCol={{ span: 8 }}\n                    wrapperCol={{ span: 16 }}\n                    style={{ maxWidth: 600 }}\n                    initialValues={editingRow ? editingRow : default_form_values}\n                    autoComplete=\"on\"\n                >\n                    <Form.Item name=\"id\" hidden={true}>\n                        <Input />\n                    </Form.Item>\n\n                    <Form.Item name=\"cluster\" label=\"Cluster\">\n                        <Select>\n                            {clusters!.clusters.map((cluster: any) => (\n                                <Select.Option value={cluster.cluster}>{cluster.cluster}</Select.Option>\n                            ))}\n                        </Select>\n                    </Form.Item>\n                    <Form.Item<FieldType>\n                        label=\"Schedule\"\n                        name=\"schedule\"\n                        rules={[{ required: true, message: 'Please provide a cron schedule for the backup' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"Incremental Schedule\"\n                        name=\"incremental_schedule\"\n                        rules={[\n                            { required: true, message: 'Please provide a cron schedule for the incremental backup' },\n                        ]}\n                    >\n                        <Input />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"Database\"\n                        name=\"database\"\n                        rules={[{ required: true, message: 'Please select a database to back up from' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"Table\"\n                        name=\"table\"\n                        rules={[{ required: false, message: 'Please select a table to back up' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"Is Sharded\"\n                        name=\"is_sharded\"\n                        initialValue=\"false\"\n                        valuePropName=\"checked\"\n                        rules={[{ required: true, message: 'Is this table sharded?' }]}\n                    >\n                        <Checkbox defaultChecked={false} />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"S3 Bucket\"\n                        name=\"bucket\"\n                        rules={[{ required: true, message: 'What S3 bucket to backup into' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"S3 Path\"\n                        name=\"path\"\n                        rules={[{ required: true, message: 'What is the path in the bucket to backup to' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"AWS Access Key ID\"\n                        name=\"aws_access_key_id\"\n                        rules={[{ required: true, message: 'AWS Access Key ID to use for access to the S3 bucket' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n\n                    <Form.Item<FieldType>\n                        label=\"AWS Secret Access Key\"\n                        name=\"aws_secret_access_key\"\n                        rules={[{ required: true, message: 'AWS Secret Access Key used to access S3 bucket' }]}\n                    >\n                        <Input />\n                    </Form.Item>\n                </Form>\n            </Modal>\n            <Table columns={columns} dataSource={backups!.backups} loading={backupsIsLoading} />\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/Clusters/Clusters.tsx",
    "content": "import React, { useEffect, useState } from 'react'\nimport { ColumnType } from 'antd/es/table'\nimport { Table, Col, Row, Tooltip, notification } from 'antd'\nimport useSWR from 'swr'\n\ninterface ClusterNode {\n    cluster: string\n    shard_num: number\n    shard_weight: number\n    replica_num: number\n    host_name: string\n    host_address: string\n    port: number\n    is_local: boolean\n    user: string\n    default_database: string\n    errors_count: number\n    slowdowns_count: number\n    estimated_recovery_time: number\n}\n\ninterface Cluster {\n    cluster: string\n    nodes: ClusterNode[]\n}\n\nexport interface Clusters {\n    clusters: Cluster[]\n}\n\nexport default function Clusters() {\n    const loadData = async (url: string) => {\n        try {\n            const res = await fetch(url)\n            const resJson = await res.json()\n            const clusters = { clusters: resJson }\n            return clusters\n        } catch (err) {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n\n    const { data, error, isLoading } = useSWR('/api/clusters', loadData)\n\n    const columns: ColumnType<ClusterNode>[] = [\n        { title: 'Cluster', dataIndex: 'cluster' },\n        { title: 'Shard Number', dataIndex: 'shard_num' },\n        { title: 'Shard Weight', dataIndex: 'shard_weight' },\n        { title: 'Replica Number', dataIndex: 'replica_num' },\n        { title: 'Host Name', dataIndex: 'host_name' },\n        { title: 'Host Address', dataIndex: 'host_address' },\n        { title: 'Port', dataIndex: 'port' },\n        { title: 'Is Local', dataIndex: 'is_local' },\n        { title: 'User', dataIndex: 'user' },\n        { title: 'Default Database', dataIndex: 'default_database' },\n        { title: 'Errors Count', dataIndex: 'errors_count' },\n        { title: 'Slowdowns Count', dataIndex: 'slowdowns_count' },\n        { title: 'Recovery Time', dataIndex: 'estimated_recovery_time' },\n    ]\n\n    return isLoading ? (\n        <div>loading...</div>\n    ) : error ? (\n        <div>error</div>\n    ) : (\n        <div>\n            <h1 style={{ textAlign: 'left' }}>Clusters</h1>\n            <p>These are the clusters that are configured in the connected ClickHouse instance</p>\n            <div>\n                <ul>\n                    {data!.clusters.map((cluster: any) => (\n                        <>\n                            <h1 key={cluster.cluster}>{cluster.cluster}</h1>\n                            <Table columns={columns} dataSource={cluster.nodes} loading={isLoading} />\n                        </>\n                    ))}\n                </ul>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/DiskUsage/DiskUsage.tsx",
    "content": "import React, { useEffect, useState } from 'react'\nimport { Pie } from '@ant-design/plots'\nimport { Card, Spin, Row, Col, notification } from 'antd'\n\nimport useSWR from 'swr'\n\ninterface NodeData {\n    node: string\n    space_used: number\n    free_space: number\n}\n\nexport function DiskUsage(): JSX.Element {\n    const loadData = async (url: string) => {\n        try {\n            const res = await fetch(url)\n            const resJson = await res.json()\n            return resJson\n        } catch {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n\n    const { data, error, isLoading } = useSWR('/api/analyze/cluster_overview', loadData)\n\n    const rows = []\n    if (!isLoading) {\n        for (let i = 0; i < data.length; i += 2) {\n            rows.push(data.slice(i, i + 2))\n        }\n    }\n\n    return isLoading ? (\n        <div>loading...</div>\n    ) : error ? (\n        <div>error</div>\n    ) : (\n        <div style={{ textAlign: 'left' }}>\n            <h1>Disk usage</h1>\n            <br />\n            <div style={{ display: 'block' }}>\n                {data.length === 0 ? (\n                    <Spin />\n                ) : (\n                    <>\n                        {rows.map((row, i) => (\n                            <Row key={`disk-usage-row-${i}`} gutter={8} style={{ marginBottom: 8 }}>\n                                <Col span={12}>\n                                    <Card>\n                                        <h2 style={{ textAlign: 'center' }}>{row[0].node}</h2>\n                                        <Pie\n                                            data={[\n                                                {\n                                                    type: 'Used disk space',\n                                                    value: row[0].space_used,\n                                                },\n                                                {\n                                                    type: 'Free disk space',\n                                                    value: row[0].free_space,\n                                                },\n                                            ]}\n                                            appendPadding={10}\n                                            angleField=\"value\"\n                                            colorField=\"type\"\n                                            radius={0.9}\n                                            label={{\n                                                type: 'inner',\n                                                offset: '-30%',\n                                                content: ({ percent }: { percent: number }) =>\n                                                    `${(percent * 100).toFixed(0)}%`,\n                                                style: {\n                                                    fontSize: 14,\n                                                    textAlign: 'center',\n                                                },\n                                            }}\n                                            interactions={[\n                                                {\n                                                    type: 'element-active',\n                                                },\n                                            ]}\n                                            style={{\n                                                display: 'block',\n                                            }}\n                                            color={['#FFB816', '#175FFF']}\n                                            tooltip={{\n                                                formatter: (v: any) => {\n                                                    return {\n                                                        name: v.type,\n                                                        value: `${(v.value / 1000000000).toFixed(2)}GB`,\n                                                    }\n                                                },\n                                            }}\n                                        />\n                                    </Card>\n                                </Col>\n                                <Col span={12}>\n                                    {row[1] ? (\n                                        <Card>\n                                            <h2 style={{ textAlign: 'center' }}>{row[1].node}</h2>\n                                            <Pie\n                                                data={[\n                                                    {\n                                                        type: 'Used disk space',\n                                                        value: row[1].space_used,\n                                                    },\n                                                    {\n                                                        type: 'Free disk space',\n                                                        value: row[1].free_space,\n                                                    },\n                                                ]}\n                                                appendPadding={10}\n                                                angleField=\"value\"\n                                                colorField=\"type\"\n                                                radius={0.9}\n                                                label={{\n                                                    type: 'inner',\n                                                    offset: '-30%',\n                                                    content: ({ percent }: { percent: number }) =>\n                                                        `${(percent * 100).toFixed(0)}%`,\n                                                    style: {\n                                                        fontSize: 14,\n                                                        textAlign: 'center',\n                                                    },\n                                                }}\n                                                interactions={[\n                                                    {\n                                                        type: 'element-active',\n                                                    },\n                                                ]}\n                                                style={{\n                                                    display: 'block',\n                                                }}\n                                                color={['#FFB816', '#175FFF']}\n                                                tooltip={{\n                                                    formatter: (v: any) => {\n                                                        return {\n                                                            name: v.type,\n                                                            value: `${(v.value / 1000000000).toFixed(2)}GB`,\n                                                        }\n                                                    },\n                                                }}\n                                            />\n                                        </Card>\n                                    ) : null}\n                                </Col>\n                            </Row>\n                        ))}\n                    </>\n                )}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/Errors/Errors.tsx",
    "content": "import React, { useEffect, useState } from 'react'\nimport { Table, notification } from 'antd'\nimport { ColumnsType } from 'antd/es/table'\nimport { isoTimestampToHumanReadable } from '../../utils/dateUtils'\n\nimport useSWR from 'swr'\n\ninterface ErrorData {\n    name: string\n    count: number\n    max_last_error_time: string\n}\n\nexport default function CollapsibleTable() {\n    const slowQueriesColumns: ColumnsType<ErrorData> = [\n        {\n            title: 'Error',\n            dataIndex: 'name',\n            render: (_, item) => (\n                <p style={{}}>\n                    <b>{item.name}</b>\n                </p>\n            ),\n        },\n        { title: 'Occurrences', dataIndex: 'count', render: (_, item) => <>{item.count}</> },\n        {\n            title: 'Most recent occurence',\n            dataIndex: 'max_last_error_time',\n            render: (_, item) => isoTimestampToHumanReadable(item.max_last_error_time),\n        },\n    ]\n\n    const loadData = async (url: string) => {\n        try {\n            const res = await fetch(url)\n            const resJson = await res.json()\n\n            const slowQueriesData = resJson.map((error: ErrorData, idx: number) => ({ key: idx, ...error }))\n            return slowQueriesData\n        } catch {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n\n    const { data, error, isLoading, mutate } = useSWR('/api/analyze/errors', loadData)\n\n    return isLoading ? (\n        <div>loading...</div>\n    ) : error ? (\n        <div>error</div>\n    ) : (\n        <div>\n            <h1 style={{ textAlign: 'left' }}>Errors</h1>\n            <br />\n            <div>\n                <Table columns={slowQueriesColumns} dataSource={data} size=\"small\" loading={isLoading} />\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/Logs/Logs.tsx",
    "content": "import { Table, Typography, Input, Card, ConfigProvider, Empty } from 'antd'\nimport React, { useEffect, useState } from 'react'\nimport { Column } from '@ant-design/charts'\n\nconst { Paragraph } = Typography\n\nexport default function Logs() {\n    // very simplistic error handling where both requests set this property\n    // mostly because the only error we expect is that the table doesn't exist\n    const [error, setError] = useState('')\n    const [logs, setLogs] = useState([])\n    const [loadingLogsFrequency, setLoadingLogsFrequency] = useState(false)\n    const [loadingLogs, setLoadingLogs] = useState(false)\n    const [logsFrequency, setLogsFrequency] = useState([])\n    const [logMessageFilter, setLogMessageFilter] = useState('')\n\n    const columns = [\n        { title: 'Time', dataIndex: 'event_time' },\n        { title: 'Level', dataIndex: 'level' },\n        { title: 'Host', dataIndex: 'hostname' },\n        {\n            title: 'Message',\n            dataIndex: 'message',\n            key: 'message',\n            render: (_: any, item: any) => {\n                return (\n                    <Paragraph\n                        style={{ maxWidth: '100%', fontFamily: 'monospace' }}\n                        ellipsis={{\n                            rows: 2,\n                            expandable: true,\n                        }}\n                    >\n                        {item.message}\n                    </Paragraph>\n                )\n            },\n        },\n    ]\n\n    const url = '/api/analyze/logs'\n\n    const fetchLogs = async (messageIlike = '') => {\n        setLoadingLogs(true)\n        const res = await fetch(url, {\n            method: 'POST',\n            body: JSON.stringify({ message_ilike: messageIlike }),\n            headers: {\n                'Content-Type': 'application/json',\n            },\n        })\n        const resJson = await res.json()\n        if (resJson.error) {\n            setError(resJson.error)\n        } else {\n            setLogs(resJson)\n        }\n        setLoadingLogs(false)\n    }\n\n    const fetchLogsFrequency = async (messageIlike = '') => {\n        setLoadingLogsFrequency(true)\n        const res = await fetch('/api/analyze/logs_frequency', {\n            method: 'POST',\n            body: JSON.stringify({ message_ilike: messageIlike }),\n            headers: {\n                'Content-Type': 'application/json',\n            },\n        })\n        const resJson = await res.json()\n        if (resJson.error) {\n            setError(resJson.error)\n        } else {\n            setLogsFrequency(resJson)\n        }\n        setLoadingLogsFrequency(false)\n    }\n\n    useEffect(() => {\n        fetchLogs(logMessageFilter)\n        fetchLogsFrequency(logMessageFilter)\n    }, [logMessageFilter])\n\n    return (\n        <>\n            <h1 style={{ textAlign: 'left' }}>Logs</h1>\n            <Input\n                style={{ boxShadow: 'none' }}\n                onChange={e => setLogMessageFilter(e.target.value)}\n                value={logMessageFilter}\n            />\n            <br />\n            <br />\n            <Card style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)' }}>\n                <Column\n                    xField=\"hour\"\n                    yField=\"total\"\n                    color=\"#ffb200\"\n                    style={{ height: 150 }}\n                    data={logsFrequency}\n                    loading={loadingLogsFrequency}\n                />\n            </Card>\n            <br />\n            <ConfigProvider\n                renderEmpty={() => (\n                    <Empty\n                        description={\n                            error === 'text_log table does not exist' ? (\n                                <>\n                                    Your ClickHouse instance does not have the <code>text_log</code> table. See{' '}\n                                    <a\n                                        href=\"https://clickhouse.com/docs/en/operations/system-tables/text_log\"\n                                        target=\"_blank\"\n                                        rel=\"noreferrer noopener\"\n                                    >\n                                        these docs\n                                    </a>{' '}\n                                    on how to configure it.\n                                </>\n                            ) : (\n                                ''\n                            )\n                        }\n                    />\n                )}\n            >\n                <Table columns={columns} dataSource={logs} loading={loadingLogs} />\n            </ConfigProvider>\n        </>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/Operations/Operations.tsx",
    "content": "// @ts-nocheck\nimport React, { useEffect, useState } from 'react'\nimport { useHistory } from 'react-router-dom'\nimport Editor from 'react-simple-code-editor'\n// @ts-ignore\nimport { highlight, languages } from 'prismjs/components/prism-core'\nimport 'prismjs/components/prism-sql'\nimport 'prismjs/themes/prism.css'\nimport { Button, Input, Progress, Table, Tabs, notification } from 'antd'\nimport TextArea from 'antd/es/input/TextArea'\nimport { ColumnType } from 'antd/es/table'\nimport { isoTimestampToHumanReadable } from '../../utils/dateUtils'\n\nimport useSWR from 'swr'\n\nconst OPERATION_STATUS_TO_HUMAN = {\n    0: 'Not started',\n    1: 'Running',\n    2: 'Completed successfully',\n    3: 'Errored',\n    4: 'Rolled back',\n    5: 'Starting',\n    6: 'Failed at startup',\n}\n\nconst statusSortOrder = [5, 1, 0, 4, 6, 3, 2]\n\nconst OPERATION_STATUS_TO_FONT_COLOR = {\n    0: 'black',\n    1: 'black',\n    2: 'green',\n    3: 'red',\n    4: 'orange',\n    5: 'black',\n    6: 'red',\n}\n\ninterface AsyncMigrationData {\n    id: number\n    name: string\n    description: string\n    status: number\n    progress: number\n    started_at: string\n    finished_at: string\n}\n\nexport function OperationControls({\n    status,\n    progress,\n    id,\n    triggerOperation,\n}: {\n    status: number\n    progress: number\n    id: number\n    triggerOperation: () => Promise<void>\n}): JSX.Element {\n    return (\n        <div style={{ width: 100 }}>\n            {[0, 4, 6].includes(status) ? (\n                <Button\n                    className=\"run-async-migration-btn\"\n                    style={{ color: 'white', background: '#1677ff' }}\n                    onClick={() => triggerOperation(id)}\n                >\n                    Run\n                </Button>\n            ) : status === 3 ? (\n                <Button danger>Rollback</Button>\n            ) : (\n                <Progress percent={progress} />\n            )}\n        </div>\n    )\n}\n\nexport function OperationsList(): JSX.Element {\n    const fetchAndUpdateOperationsIfNeeded = async (url: string) => {\n        const response = await fetch(url)\n        const responseJson = await response.json()\n        const results = responseJson.results\n        if (JSON.stringify(results) !== JSON.stringify(operations)) {\n            return results\n        }\n    }\n\n    const triggerOperation = async id => {\n        await fetch(`/api/async_migrations/${id}/trigger`, { method: 'POST' })\n        await fetchAndUpdateOperationsIfNeeded()\n    }\n\n    const { data: operations, error, isLoading, mutate } = useSWR(\n        '/api/async_migrations',\n        fetchAndUpdateOperationsIfNeeded\n    )\n\n    useEffect(() => {\n        const intervalId = setInterval(() => {\n            mutate('/api/async_migrations')\n        }, 5000)\n        return () => {\n            try {\n                clearInterval(intervalId)\n            } catch {}\n        }\n    }, [])\n\n    const columns: ColumnType<AsyncMigrationData>[] = [\n        {\n            title: 'Name',\n            render: (_, migration) => migration.name,\n        },\n        {\n            title: 'Description',\n            render: (_, migration) => migration.description,\n        },\n        {\n            title: 'Status',\n            render: (_, migration) => (\n                <span style={{ color: OPERATION_STATUS_TO_FONT_COLOR[migration.status] }}>\n                    {OPERATION_STATUS_TO_HUMAN[migration.status]}\n                </span>\n            ),\n            sorter: (a, b) => statusSortOrder.indexOf(a.status) - statusSortOrder.indexOf(b.status),\n            defaultSortOrder: 'ascend',\n        },\n        {\n            title: 'Started at',\n            render: (_, migration) => (migration.started_at ? isoTimestampToHumanReadable(migration.started_at) : ''),\n        },\n        {\n            title: 'Finished at',\n            render: (_, migration) => (migration.finished_at ? isoTimestampToHumanReadable(migration.finished_at) : ''),\n        },\n        {\n            title: '',\n            render: (_, migration) => (\n                <OperationControls\n                    status={migration.status}\n                    progress={migration.progress}\n                    id={migration.id}\n                    triggerOperation={triggerOperation}\n                />\n            ),\n        },\n    ]\n\n    return isLoading ? (\n        <div>loading...</div>\n    ) : error ? (\n        <div>error</div>\n    ) : (\n        <Table columns={columns} dataSource={operations} />\n    )\n}\n\nexport function CreateNewOperation(): JSX.Element {\n    const history = useHistory()\n\n    const [operationOperationsCount, setOperationOperationsCount] = useState(1)\n\n    const [code, setCode] = useState({})\n\n    const createOperation = async () => {\n        const form = document.getElementById('create-migration-form')\n        const formData = new FormData(form)\n\n        const operations: string[] = []\n        const rollbackOperations: string[] = []\n        const operationData = {\n            name: '',\n            description: '',\n            operations: operations,\n            rollbackOperations: rollbackOperations,\n        }\n        for (const [key, value] of formData.entries()) {\n            if (key.includes('operation')) {\n                operations.push(value)\n                continue\n            }\n            if (key.includes('rollback')) {\n                rollbackOperations.push(value)\n                continue\n            }\n            operationData[key] = value\n        }\n\n        const res = await fetch('/api/async_migrations', {\n            method: 'POST',\n            body: JSON.stringify(operationData),\n            headers: {\n                'Content-Type': 'application/json',\n            },\n        })\n        if (String(res.status)[0] === '2') {\n            history.go(0)\n        } else {\n            notification.error({\n                message: 'Error creating operation! Check if you do not have an operation with the same name already.',\n            })\n        }\n    }\n\n    return (\n        <div>\n            <form style={{ textAlign: 'left', marginLeft: 20, overflowY: 'auto' }} id=\"create-migration-form\">\n                <h3>Details</h3>\n\n                <Input id=\"create-migration-form-name\" name=\"name\" placeholder=\"Name\" style={{ width: 400 }} />\n                <br />\n                <br />\n                <TextArea\n                    id=\"create-migration-form-description\"\n                    name=\"description\"\n                    placeholder=\"Description\"\n                    style={{ width: 800 }}\n                    rows={3}\n                />\n                <br />\n                <br />\n\n                <h3>Operations</h3>\n\n                {[...Array(operationOperationsCount)].map((_, i) => (\n                    <span key={i}>\n                        <h4>#{i + 1}</h4>\n\n                        <Editor\n                            id={`create-migration-form-operation-${i + 1}`}\n                            name={`operation-${i + 1}`}\n                            value={\n                                code[`operation-${i + 1}`] ||\n                                `CREATE TABLE test_table ( foo String ) Engine=MergeTree() ORDER BY foo`\n                            }\n                            onValueChange={value => setCode({ ...code, [`operation-${i + 1}`]: value })}\n                            highlight={code => highlight(code, languages.sql)}\n                            padding={10}\n                            style={{\n                                fontFamily: '\"Fira code\", \"Fira Mono\", monospace',\n                                fontSize: 14,\n                                width: 800,\n                                minHeight: 200,\n                                border: '1px solid #d9d9d9',\n                                borderRadius: 4,\n                                background: 'white',\n                                boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)',\n                            }}\n                            rows={5}\n                        />\n                        <br />\n                        <br />\n                        <Editor\n                            id={`create-migration-form-rollback-${i + 1}`}\n                            name={`rollback-${i + 1}`}\n                            value={code[`rollback-${i + 1}`] || `DROP TABLE IF EXISTS test_table`}\n                            onValueChange={value => setCode({ ...code, [`rollback-${i + 1}`]: value })}\n                            highlight={code => highlight(code, languages.sql)}\n                            padding={10}\n                            style={{\n                                fontFamily: '\"Fira code\", \"Fira Mono\", monospace',\n                                fontSize: 14,\n                                width: 800,\n                                minHeight: 200,\n                                border: '1px solid #d9d9d9',\n                                borderRadius: 4,\n                                background: 'white',\n                                boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)',\n                            }}\n                            rows={5}\n                        />\n                        <br />\n                        <br />\n                    </span>\n                ))}\n                {operationOperationsCount > 1 ? (\n                    <>\n                        <Button onClick={() => setOperationOperationsCount(operationOperationsCount - 1)} danger>\n                            -\n                        </Button>{' '}\n                    </>\n                ) : null}\n                <Button\n                    onClick={() => setOperationOperationsCount(operationOperationsCount + 1)}\n                    style={{ color: 'rgb(22 166 255)', borderColor: 'rgb(22 166 255)' }}\n                >\n                    +\n                </Button>\n            </form>\n            <div style={{ textAlign: 'center' }}>\n                <Button style={{ color: 'white', background: '#1677ff' }} variant=\"contained\" onClick={createOperation}>\n                    Save\n                </Button>\n            </div>\n        </div>\n    )\n}\n\nexport function Operations(): JSX.Element {\n    return (\n        <div style={{ display: 'block', margin: 'auto' }}>\n            <h1 style={{ textAlign: 'left' }}>Operations (Alpha)</h1>\n            <p>\n                Create long-running operations to run in the background in your ClickHouse cluster. Useful for large\n                data migrations, specify SQL commands to run in order with corresponding rollbacks, such that if the\n                operation fails, you rollback to a safe state.\n            </p>\n            <p>\n                <b>Please exercise caution!</b> This functionality is still in Alpha.\n            </p>\n            <Tabs\n                items={[\n                    {\n                        key: 'list',\n                        label: `Operations`,\n                        children: <OperationsList />,\n                    },\n                    {\n                        key: 'create',\n                        label: `Create new operation`,\n                        children: <CreateNewOperation />,\n                    },\n                ]}\n                defaultActiveKey=\"list\"\n            />\n\n            <br />\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/Overview/Overview.tsx",
    "content": "import React, { useEffect, useState } from 'react'\nimport { Line } from '@ant-design/charts'\nimport { Card, Col, Row, Tooltip, notification } from 'antd'\nimport InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined'\nimport { clickhouseTips } from './tips'\nimport useSWR from 'swr'\n\ninterface MetricData {\n    day_start: string\n    total: number\n}\n\ninterface QueryGraphsData {\n    execution_count: MetricData[]\n    memory_usage: MetricData[]\n    read_bytes: MetricData[]\n    cpu: MetricData[]\n}\n\nexport default function Overview() {\n    const loadData = async (url: string) => {\n        try {\n            const res = await fetch(url)\n            const resJson = await res.json()\n            const execution_count = resJson.execution_count\n            const memory_usage = resJson.memory_usage\n            const read_bytes = resJson.read_bytes\n            const cpu = resJson.cpu\n            return { execution_count, memory_usage, read_bytes, cpu }\n        } catch (err) {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n\n    const { data, error, isLoading } = useSWR('/api/analyze/query_graphs', loadData)\n\n    const now = new Date()\n    const dayOfTheYear = Math.floor(\n        (now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / (1000 * 60 * 60 * 24)\n    )\n\n    console.log(data, error, isLoading)\n\n    return isLoading ? (\n        <div>loading</div>\n    ) : error ? (\n        <div>error</div>\n    ) : (\n        <div>\n            <h1 style={{ textAlign: 'left' }}>Overview</h1>\n            <Card title=\"💡 ClickHouse tip of the day\">{clickhouseTips[dayOfTheYear % clickhouseTips.length]}</Card>\n            <br />\n            <Row gutter={8} style={{ paddingBottom: 8 }}>\n                <Col span={12}>\n                    <Card style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)' }} title=\"Number of queries\">\n                        <Line\n                            data={data!.execution_count.map((dataPoint: any) => ({\n                                ...dataPoint,\n                                day_start: dataPoint.day_start.split('T')[0],\n                            }))}\n                            xField={'day_start'}\n                            yField={'total'}\n                            xAxis={{ tickCount: 5 }}\n                            style={{ padding: 20, height: 300 }}\n                            color=\"#ffb200\"\n                            loading={isLoading}\n                        />\n                    </Card>\n                </Col>\n                <Col span={12}>\n                    <Card style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)' }} title=\"Data read (GB)\">\n                        <Line\n                            data={data!.read_bytes.map((dataPoint: any) => ({\n                                day_start: dataPoint.day_start.split('T')[0],\n                                total: dataPoint.total / 1000000000,\n                            }))}\n                            xField={'day_start'}\n                            yField={'total'}\n                            xAxis={{ tickCount: 5 }}\n                            style={{ padding: 20, height: 300 }}\n                            color=\"#ffb200\"\n                            loading={isLoading}\n                        />\n                    </Card>\n                </Col>\n            </Row>\n            <Row gutter={8}>\n                <Col span={12}>\n                    <Card style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)' }} title=\"Memory usage (GB)\">\n                        <Line\n                            data={data!.memory_usage.map((dataPoint: any) => ({\n                                day_start: dataPoint.day_start.split('T')[0],\n                                total: dataPoint.total / 1000000000,\n                            }))}\n                            xField={'day_start'}\n                            yField={'total'}\n                            style={{ padding: 20, height: 300 }}\n                            color=\"#ffb200\"\n                            loading={isLoading}\n                        />\n                    </Card>\n                </Col>\n                <Col span={12}>\n                    <Card\n                        style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)' }}\n                        title={\n                            <>\n                                CPU usage (seconds){' '}\n                                <Tooltip\n                                    title={`Calculated from OSCPUVirtualTimeMicroseconds metric from ClickHouse query log's ProfileEvents.`}\n                                >\n                                    <InfoCircleOutlined />\n                                </Tooltip>\n                            </>\n                        }\n                    >\n                        <Line\n                            data={data!.cpu.map((dataPoint: any) => ({\n                                day_start: dataPoint.day_start.split('T')[0],\n                                total: dataPoint.total,\n                            }))}\n                            xField={'day_start'}\n                            yField={'total'}\n                            style={{ padding: 20, height: 300 }}\n                            color=\"#ffb200\"\n                            loading={isLoading}\n                        />\n                    </Card>\n                </Col>\n            </Row>\n            <br />\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/Overview/tips.ts",
    "content": "export const clickhouseTips = [\n    `Consider benchmarking different join algorithms if your queries contain expensive joins. You may find that algorithms other than the default perform significantly better for your workloads.`,\n    `If you store JSON data in a VARCHAR column, consider materializing frequently acessed properties using materialized columns for much faster queries.`,\n    `You can use the log_comment setting to add metadata to queries that will show up on the query log, including on distributed queries. For instance, you can add a stringified JSON object as a comment to tag queries for analysis.`,\n    `Dictionaries can be an effective tool in large data migrations or backfills.`,\n    `Make sure you push as many of your query filters down to the innermost subquery for better performance. Unlike other databases, ClickHouse does not have a query planner, so you want to minimize the amount of data fetched from other shards.`,\n    `If a column stores values with low cardinality (e.g. country codes), use the LowCardinality data type to improve performance and reduce storage usage. A low cardinality VARCHAR would be defined as LowCardinality(VARCHAR) in the table creation query.`,\n    `quantile is not an exact function but rather a sampled approximation. Use quantileExactExclusive for exact results.`,\n    `ClickHouse is great at introspection, and its system tables contain a lot of metadata about the server. Learning what information is available where can be a great tool in debugging issues and mapping out areas of improvement. A lot of HouseWatch features are effectively wrappers over ClickHouse system tables.`,\n    `Killing a mutation with KILL MUTATION does not kill ongoing merges triggered by the mutation. If you absolutely need to stop ongoing merges as well, you should use SYSTEM STOP MERGES. However, you should not keep merges off for too long, as you may end up with too many parts unmerged, which is problematic for ClickHouse.`,\n    `Set mutations_sync=2 on a mutation to wait for all replicas to complete the mutation.`,\n    `ClickHouse does not support changing table engines in place, requiring you thus to create a new table and move data to it. However, rather than using INSERT to move the data over, you can use ATTACH PARTITION for near-instant operations instead, provided the tables contain the same \"structure\" i.e. same columns/ORDER BY/PARTITION BY.`,\n    `Consider benchmarking different compression algorithms for large columns for more efficient queries and storage usage.`,\n]\n"
  },
  {
    "path": "frontend/src/pages/QueryEditor/Benchmark.tsx",
    "content": "import { Button, Row, Col, Card, Divider, Spin } from 'antd'\nimport React, { useState } from 'react'\n// @ts-ignore\nimport { highlight, languages } from 'prismjs/components/prism-core'\nimport 'prismjs/components/prism-sql'\nimport 'prismjs/themes/prism.css'\nimport Editor from 'react-simple-code-editor'\nimport { Column } from '@ant-design/charts'\nimport useSWR from 'swr'\n\nexport interface BenchmarkingData {\n    benchmarking_result: {\n        query_version: string\n        cpu: number\n        read_bytes: number\n        memory_usage: number\n        duration_ms: number\n        network_receive_bytes: number\n        read_bytes_from_other_shards: number\n    }[]\n}\n\nconst DEFAULT_QUERY1 = `SELECT number FROM system.errors errors\nJOIN (\n    SELECT * FROM system.numbers LIMIT 1000\n) numbers\nON numbers.number = toUInt64(errors.code)\nSETTINGS join_algorithm = 'default'\n`\n\nconst DEFAULT_QUERY2 = `SELECT number FROM system.errors errors\nJOIN (\n    SELECT * FROM system.numbers LIMIT 1000\n) numbers\nON numbers.number = toUInt64(errors.code)\nSETTINGS join_algorithm = 'parallel_hash'\n`\n\nexport default function QueryBenchmarking() {\n    const [query1, setQuery1] = useState(DEFAULT_QUERY1)\n    const [query2, setQuery2] = useState(DEFAULT_QUERY2)\n    const [runningBenchmark, setRunningBenchmark] = useState(false)\n    const [error, setError] = useState<{ error_location: string; error: string } | null>(null)\n    const [data, setData] = useState<BenchmarkingData | null>(null)\n\n    const runBenchmark = async () => {\n        setRunningBenchmark(true)\n        try {\n            setData(null)\n            setError(null)\n            const res = await fetch('/api/analyze/benchmark', {\n                method: 'POST',\n                body: JSON.stringify({ query1, query2 }),\n                headers: {\n                    'Content-Type': 'application/json',\n                },\n            })\n            const resJson = await res.json()\n            if (resJson.error) {\n                setError(resJson)\n            } else {\n                setData(resJson)\n            }\n        } catch (error) {\n            setError({ error: String(error), error_location: '' })\n        }\n        setRunningBenchmark(false)\n    }\n\n    return (\n        <>\n            <p>\n                A simple benchmarking tool for analyzing how one query performs against another. Useful for testing\n                different approaches to writing the same query when optimizing for performance. Note that this tool only\n                runs each query once, and page cache is not cleared (this requires manual action on the node itself), so\n                results are best taken as an indication of direction than a full-on benchmark.\n            </p>\n            <Divider />\n            <Row gutter={4}>\n                <Col span={12}>\n                    <p style={{ textAlign: 'center' }}>\n                        <b>Control</b>\n                    </p>\n                    <Editor\n                        value={query1}\n                        onValueChange={code => setQuery1(code)}\n                        highlight={code => highlight(code, languages.sql)}\n                        padding={10}\n                        style={{\n                            fontFamily: '\"Fira code\", \"Fira Mono\", monospace',\n                            fontSize: 16,\n                            minHeight: 350,\n                            border: '1px solid rgb(216, 216, 216)',\n                            borderRadius: 4,\n                            boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)',\n                            marginBottom: 5,\n                        }}\n                    />\n                </Col>\n                <Col span={12}>\n                    <p style={{ textAlign: 'center' }}>\n                        <b>Test</b>\n                    </p>\n                    <Editor\n                        value={query2}\n                        onValueChange={code => setQuery2(code)}\n                        highlight={code => highlight(code, languages.sql)}\n                        padding={10}\n                        style={{\n                            fontFamily: '\"Fira code\", \"Fira Mono\", monospace',\n                            fontSize: 16,\n                            minHeight: 350,\n                            border: '1px solid rgb(216, 216, 216)',\n                            borderRadius: 4,\n                            boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)',\n                            marginBottom: 5,\n                        }}\n                    />\n                </Col>\n            </Row>\n\n            <Button\n                type=\"primary\"\n                style={{ width: '100%', boxShadow: 'none' }}\n                onClick={runBenchmark}\n                disabled={runningBenchmark}\n            >\n                Benchmark\n            </Button>\n            <br />\n            <br />\n            {error ? (\n                <>\n                    <Card\n                        style={{ textAlign: 'center' }}\n                        title={\n                            <>\n                                {error.error_location === 'benchmark'\n                                    ? 'Error loading benchmark results'\n                                    : `Error on ${error.error_location} query`}\n                            </>\n                        }\n                    >\n                        <code style={{ color: '#c40000' }}>{error.error}</code>\n                    </Card>\n                </>\n            ) : data && data.benchmarking_result ? (\n                <>\n                    <Row gutter={8} style={{ marginBottom: 4, height: 350 }}>\n                        <Col span={12}>\n                            <Card\n                                style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)', height: 350 }}\n                                title=\"Duration (ms)\"\n                            >\n                                <Column\n                                    data={data.benchmarking_result || []}\n                                    xField=\"query_version\"\n                                    yField=\"duration_ms\"\n                                    height={250}\n                                    color={({ query_version }) => (query_version === 'Control' ? '#6495F9' : '#F7DA46')}\n                                />\n                            </Card>\n                        </Col>\n                        <Col span={12}>\n                            <Card\n                                style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)', height: 350 }}\n                                title=\"Read bytes\"\n                            >\n                                <Column\n                                    data={data.benchmarking_result || []}\n                                    xField=\"query_version\"\n                                    yField=\"read_bytes\"\n                                    height={250}\n                                    color={({ query_version }) => (query_version === 'Control' ? '#6495F9' : '#F7DA46')}\n                                />\n                            </Card>\n                        </Col>\n                    </Row>\n\n                    <Row gutter={8} style={{ marginBottom: 4, height: 350 }}>\n                        <Col span={12}>\n                            <Card\n                                style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)', height: 350 }}\n                                title=\"CPU usage\"\n                            >\n                                <Column\n                                    data={data.benchmarking_result || []}\n                                    xField=\"query_version\"\n                                    yField=\"cpu\"\n                                    height={250}\n                                    color={({ query_version }) => (query_version === 'Control' ? '#6495F9' : '#F7DA46')}\n                                />\n                            </Card>\n                        </Col>\n                        <Col span={12}>\n                            <Card\n                                style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)', height: 350 }}\n                                title=\"Memory usage\"\n                            >\n                                <Column\n                                    data={data.benchmarking_result || []}\n                                    xField=\"query_version\"\n                                    yField=\"memory_usage\"\n                                    height={250}\n                                    color={({ query_version }) => (query_version === 'Control' ? '#6495F9' : '#F7DA46')}\n                                />\n                            </Card>\n                        </Col>\n                    </Row>\n\n                    <Row gutter={8} style={{ marginBottom: 4, height: 350 }}>\n                        <Col span={12}>\n                            <Card\n                                style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)', height: 350 }}\n                                title=\"Bytes received from network\"\n                            >\n                                <Column\n                                    data={data.benchmarking_result || []}\n                                    xField=\"query_version\"\n                                    yField=\"network_receive_bytes\"\n                                    height={250}\n                                    color={({ query_version }) => (query_version === 'Control' ? '#6495F9' : '#F7DA46')}\n                                />\n                            </Card>\n                        </Col>\n                        <Col span={12}>\n                            <Card\n                                style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)', height: 350 }}\n                                title=\"Read bytes from other shards\"\n                            >\n                                <Column\n                                    data={data.benchmarking_result || []}\n                                    xField=\"query_version\"\n                                    yField=\"read_bytes_from_other_shards\"\n                                    height={250}\n                                    color={({ query_version }) => (query_version === 'Control' ? '#6495F9' : '#F7DA46')}\n                                />\n                            </Card>\n                        </Col>\n                    </Row>\n                </>\n            ) : runningBenchmark ? (\n                <div style={{ margin: 0, textAlign: 'center' }}>\n                    <Spin />\n                </div>\n            ) : null}\n        </>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/QueryEditor/QueryEditor.tsx",
    "content": "import { Table, Button, ConfigProvider, Row, Col, Tooltip, Modal, Input, notification } from 'antd'\nimport React, { useState } from 'react'\n// @ts-ignore\nimport { highlight, languages } from 'prismjs/components/prism-core'\nimport 'prismjs/components/prism-sql'\nimport 'prismjs/themes/prism.css'\nimport Editor from 'react-simple-code-editor'\nimport { v4 as uuidv4 } from 'uuid'\nimport SaveOutlined from '@ant-design/icons/SaveOutlined'\n\nfunction CreateSavedQueryModal({\n    modalOpen = false,\n    setModalOpen,\n    saveQuery,\n}: {\n    modalOpen: boolean\n    setModalOpen: (open: boolean) => void\n    saveQuery: (name: string) => Promise<void>\n}) {\n    const [queryName, setQueryName] = useState<string>('')\n\n    return (\n        <>\n            <Modal\n                title=\"Query name\"\n                open={modalOpen}\n                onOk={() => saveQuery(queryName)}\n                onCancel={() => setModalOpen(false)}\n            >\n                <Input value={queryName} onChange={e => setQueryName(e.target.value)} />\n            </Modal>\n        </>\n    )\n}\n\nexport default function QueryEditor() {\n    const [sql, setSql] = useState(\n        'SELECT type, query, query_duration_ms, formatReadableSize(memory_usage)\\nFROM system.query_log\\nWHERE type > 1 AND is_initial_query\\nORDER BY event_time DESC\\nLIMIT 10'\n    )\n    const [error, setError] = useState('')\n    const [data, setData] = useState([{}])\n    const [runningQueryId, setRunningQueryId] = useState<null | string>(null)\n    const [modalOpen, setModalOpen] = useState(false)\n\n    const columns = data.length > 0 ? Object.keys(data[0]).map(column => ({ title: column, dataIndex: column })) : []\n\n    const saveQuery = async (queryName: string) => {\n        try {\n            const res = await fetch('/api/saved_queries', {\n                method: 'POST',\n                body: JSON.stringify({ name: queryName, query: sql }),\n                headers: {\n                    'Content-Type': 'application/json',\n                },\n            })\n            if (String(res.status)[0] !== '2') {\n                throw new Error()\n            }\n            setModalOpen(false)\n            notification.success({ message: 'Query saved successfully' })\n        } catch (error) {\n            notification.error({ message: `Couldn't save query` })\n        }\n    }\n\n    const query = async (sql = '') => {\n        const queryId = uuidv4()\n        setRunningQueryId(queryId)\n        try {\n            setData([])\n            const res = await fetch('/api/analyze/query', {\n                method: 'POST',\n                body: JSON.stringify({ sql, query_id: queryId }),\n                headers: {\n                    'Content-Type': 'application/json',\n                },\n            })\n            const resJson = await res.json()\n            if (resJson.error) {\n                setError(resJson.error)\n            } else {\n                setData(resJson.result)\n            }\n        } catch (error) {\n            setError(String(error))\n        }\n        setRunningQueryId(null)\n    }\n\n    const cancelRunningQuery = async () => {\n        if (runningQueryId) {\n            await fetch(`http://localhost:8000/api/analyze/${runningQueryId}/kill_query`, {\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/x-www-form-urlencoded',\n                },\n                body: new URLSearchParams({\n                    query_id: runningQueryId,\n                }),\n            })\n            setRunningQueryId(null)\n            setData([{}])\n        }\n    }\n\n    return (\n        <>\n            <Row>\n                <Col span={23}>\n                    {' '}\n                    <p>\n                        <i>Note that HouseWatch does not add limits to queries automatically.</i>\n                    </p>\n                </Col>\n                <Col span={1}>\n                    {data && Object.keys(data[0] || {}).length > 0 ? (\n                        <Tooltip title=\"Save query\">\n                            <Button style={{ background: 'transparent' }} onClick={() => setModalOpen(true)}>\n                                <SaveOutlined />\n                            </Button>\n                        </Tooltip>\n                    ) : null}\n                </Col>\n            </Row>\n\n            <Editor\n                value={sql}\n                onValueChange={code => setSql(code)}\n                highlight={code => highlight(code, languages.sql)}\n                padding={10}\n                style={{\n                    fontFamily: '\"Fira code\", \"Fira Mono\", monospace',\n                    fontSize: 16,\n                    minHeight: 200,\n                    border: '1px solid rgb(216, 216, 216)',\n                    borderRadius: 4,\n                    boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)',\n                    marginBottom: 5,\n                }}\n            />\n            <Button\n                type=\"primary\"\n                style={{ width: '100%', boxShadow: 'none' }}\n                onClick={() => (runningQueryId ? cancelRunningQuery() : query(sql))}\n            >\n                {runningQueryId ? 'Cancel' : 'Run'}\n            </Button>\n            <br />\n            <br />\n\n            <ConfigProvider renderEmpty={() => <p style={{ color: '#c40000', fontFamily: 'monospace' }}>{error}</p>}>\n                <Table columns={columns} dataSource={data} loading={!error && data.length < 1} scroll={{ x: 400 }} />\n            </ConfigProvider>\n            <CreateSavedQueryModal setModalOpen={setModalOpen} saveQuery={saveQuery} modalOpen={modalOpen} />\n        </>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/QueryEditor/QueryEditorPage.tsx",
    "content": "import React from 'react'\nimport SavedQueries from './SavedQueries'\nimport QueryEditor from './QueryEditor'\nimport { Tabs } from 'antd'\nimport QueryBenchmarking from './Benchmark'\nimport { useHistory } from 'react-router-dom'\n\nexport default function QueryEditorPage({ match }: { match: { params: { tab: string; id: string } } }) {\n    const history = useHistory()\n\n    let defaultActiveTab = 'run'\n\n    if (['run', 'saved_queries', 'benchmark'].includes(match.params.tab)) {\n        defaultActiveTab = match.params.tab\n    } else {\n        history.push('/query_editor/run')\n    }\n\n    return (\n        <>\n            <h1 style={{ textAlign: 'left' }}>Query editor</h1>\n\n            <Tabs\n                items={[\n                    {\n                        key: 'run',\n                        label: `Run query`,\n                        children: <QueryEditor />,\n                    },\n                    {\n                        key: 'saved_queries',\n                        label: `Saved queries`,\n                        children: <SavedQueries match={match} />,\n                    },\n                    {\n                        key: 'benchmark',\n                        label: `Query benchmarking`,\n                        children: <QueryBenchmarking />,\n                    },\n                ]}\n                defaultActiveKey={defaultActiveTab}\n                onChange={(tab) => history.push(`/query_editor/${tab}`)}\n            />\n        </>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/QueryEditor/SavedQueries.tsx",
    "content": "import { Table, Button, Row, Col, Tooltip } from 'antd'\nimport React, { useEffect, useState } from 'react'\nimport { ColumnType } from 'antd/es/table'\nimport SavedQuery from './SavedQuery'\nimport ReloadOutlined from '@ant-design/icons/ReloadOutlined'\nimport { useHistory } from 'react-router-dom'\nimport { isoTimestampToHumanReadable } from '../../utils/dateUtils'\n\nexport interface SavedQueryData {\n    id: number\n    name: string\n    query: string\n}\n\nexport default function SavedQueries({ match }: { match: { params: { id: string } } }) {\n    const [savedQueries, setSavedQueries] = useState([])\n    const [activeQuery, setActiveQuery] = useState<SavedQueryData | null>(null)\n    const history = useHistory()\n\n    const loadData = async () => {\n        const res = await fetch('/api/saved_queries')\n        const resJson = await res.json()\n        setSavedQueries(resJson.results)\n        if (match && match.params && match.params.id) {\n            setActiveQuery(resJson.results.find((q: SavedQueryData) => q.id === Number(match.params.id)) || null)\n        }\n    }\n\n    useEffect(() => {\n        loadData()\n    }, [])\n\n    const columns: ColumnType<{ name: string; id: number; query: string; created_at: string }>[] = [\n        {\n            title: 'Name',\n            dataIndex: 'name',\n            render: (_, item) => (\n                <span\n                    style={{ color: '#1677ff', cursor: 'pointer' }}\n                    onClick={() => {\n                        setActiveQuery(item)\n                        history.push(`/query_editor/saved_queries/${item.id}`)\n                    }}\n                >\n                    {item.name}\n                </span>\n            ),\n        },\n        {\n            title: 'Created at',\n            render: (_, item) => (item.created_at ? isoTimestampToHumanReadable(item.created_at) : ''),\n        },\n    ]\n\n    return (\n        <>\n            {activeQuery ? (\n                <>\n                    <a\n                        onClick={() => {\n                            setActiveQuery(null)\n                            history.push(`/query_editor/saved_queries`)\n                        }}\n                        style={{ float: 'right' }}\n                    >\n                        ← Return to saved queries list\n                    </a>\n                    <SavedQuery {...activeQuery} />\n                </>\n            ) : (\n                <>\n                    <Row style={{ marginBottom: 2 }}>\n                        <Col span={23}></Col>\n                        <Col span={1}>\n                            <Tooltip title=\"Refresh list\">\n                                <Button style={{ background: 'transparent' }} onClick={loadData}>\n                                    <ReloadOutlined />\n                                </Button>\n                            </Tooltip>\n                        </Col>\n                    </Row>\n                    <Table columns={columns} dataSource={savedQueries} />\n                </>\n            )}\n        </>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/QueryEditor/SavedQuery.tsx",
    "content": "import { Table, ConfigProvider } from 'antd'\nimport React, { useEffect, useState } from 'react'\n// @ts-ignore\nimport { highlight, languages } from 'prismjs/components/prism-core'\nimport 'prismjs/components/prism-sql'\nimport 'prismjs/themes/prism.css'\nimport Editor from 'react-simple-code-editor'\nimport { SavedQueryData } from './SavedQueries'\n\nexport default function SavedQuery({ id, query, name }: SavedQueryData) {\n    const [error, setError] = useState('')\n    const [data, setData] = useState([{}])\n\n    const columns = data.length > 0 ? Object.keys(data[0]).map((column) => ({ title: column, dataIndex: column })) : []\n\n    const loadData = async () => {\n        try {\n            setData([])\n            setError('')\n            const res = await fetch('/api/analyze/query', {\n                method: 'POST',\n                body: JSON.stringify({ sql: query }),\n                headers: {\n                    'Content-Type': 'application/json',\n                },\n            })\n            const resJson = await res.json()\n            if (resJson.error) {\n                setError(resJson.error)\n            } else {\n                setData(resJson.result)\n            }\n        } catch (error) {\n            setError(String(error))\n        }\n    }\n\n    useEffect(() => {\n        loadData()\n    }, [])\n\n    return (\n        <>\n            <h2 style={{ textAlign: 'left' }}>{name}</h2>\n            <Editor\n                value={query}\n                onValueChange={() => {}}\n                highlight={(code) => highlight(code, languages.sql)}\n                padding={10}\n                style={{\n                    fontFamily: '\"Fira code\", \"Fira Mono\", monospace',\n                    fontSize: 16,\n                    minHeight: 200,\n                    border: '1px solid rgb(216, 216, 216)',\n                    borderRadius: 4,\n                    boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)',\n                    marginBottom: 5,\n                }}\n                disabled\n            />\n            <br />\n            <br />\n\n            <ConfigProvider renderEmpty={() => <p style={{ color: '#c40000', fontFamily: 'monospace' }}>{error}</p>}>\n                <Table columns={columns} dataSource={data} loading={!error && data.length < 1} scroll={{ x: 400 }} />\n            </ConfigProvider>\n        </>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/RunningQueries/RunningQueries.tsx",
    "content": "import { Table, Button, notification, Typography, Tooltip, Spin } from 'antd'\nimport { usePollingEffect } from '../../utils/usePollingEffect'\nimport React, { useState } from 'react'\nimport { ColumnType } from 'antd/es/table'\n\nconst { Paragraph } = Typography\n\ninterface RunningQueryData {\n    query: string\n    read_rows: number\n    read_rows_readable: string\n    query_id: string\n    total_rows_approx: number\n    total_rows_approx_readable: string\n    elapsed: number\n    memory_usage: string\n}\n\nfunction KillQueryButton({ queryId }: any) {\n    const [isLoading, setIsLoading] = useState(false)\n    const [isKilled, setIsKilled] = useState(false)\n\n    const killQuery = async () => {\n        setIsLoading(true)\n        try {\n            const res = await fetch(`/api/analyze/${queryId}/kill_query`, {\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/x-www-form-urlencoded',\n                },\n                body: new URLSearchParams({\n                    query_id: queryId,\n                }),\n            })\n            setIsKilled(true)\n            setIsLoading(false)\n            return await res.json()\n        } catch (err) {\n            setIsLoading(false)\n            notification.error({\n                message: 'Killing query failed',\n            })\n        }\n    }\n    return (\n        <>\n            {isKilled ? (\n                <Button disabled>Query killed</Button>\n            ) : (\n                <Button danger onClick={killQuery} loading={isLoading}>\n                    Kill query\n                </Button>\n            )}\n        </>\n    )\n}\n\nexport default function RunningQueries() {\n    const [runningQueries, setRunningQueries] = useState([])\n    const [loadingRunningQueries, setLoadingRunningQueries] = useState(false)\n\n    const columns: ColumnType<RunningQueryData>[] = [\n        {\n            title: 'Query',\n            dataIndex: 'normalized_query',\n            key: 'query',\n            render: (_: any, item) => {\n                let index = 0\n                return (\n                    <Paragraph\n                        style={{ maxWidth: '100%', fontFamily: 'monospace' }}\n                        ellipsis={{\n                            rows: 2,\n                            expandable: true,\n                        }}\n                    >\n                        {item.query.replace(/(\\?)/g, () => {\n                            index = index + 1\n                            return '$' + index\n                        })}\n                    </Paragraph>\n                )\n            },\n        },\n        { title: 'User', dataIndex: 'user' },\n        { title: 'Elapsed time', dataIndex: 'elapsed' },\n        {\n            title: 'Rows read',\n            dataIndex: 'read_rows',\n            render: (_: any, item) => (\n                <Tooltip title={`~${item.read_rows}/${item.total_rows_approx}`}>\n                    ~{item.read_rows_readable}/{item.total_rows_approx_readable}\n                </Tooltip>\n            ),\n        },\n        { title: 'Memory Usage', dataIndex: 'memory_usage' },\n        {\n            title: 'Actions',\n            render: (_: any, item) => <KillQueryButton queryId={item.query_id} />,\n        },\n    ]\n\n    usePollingEffect(\n        async () => {\n            setLoadingRunningQueries(true)\n            const res = await fetch('/api/analyze/running_queries')\n            const resJson = await res.json()\n            setRunningQueries(resJson)\n            setLoadingRunningQueries(false)\n        },\n        [],\n        { interval: 5000 }\n    )\n\n    return (\n        <>\n            <h1 style={{ textAlign: 'left' }}>Running queries {loadingRunningQueries ? <Spin /> : null}</h1>\n            <br />\n            <Table\n                columns={columns}\n                dataSource={runningQueries}\n                loading={runningQueries.length == 0 && loadingRunningQueries}\n            />\n        </>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/SchemaStats/SchemaStats.tsx",
    "content": "// @ts-nocheck\nimport React, { useEffect, useState } from 'react'\nimport { Treemap } from '@ant-design/charts'\nimport { Spin, Table } from 'antd'\n\nimport { useHistory } from 'react-router-dom'\n\nexport default function Schema() {\n    const history = useHistory()\n    const testSchemaData = {\n        name: 'root',\n        children: [],\n    }\n\n    const [schema, setSchema] = useState([])\n    const defaultConfig: React.ComponentProps<typeof Treemap> = {\n        data: testSchemaData,\n        colorField: 'name',\n        style: { cursor: 'pointer' },\n        label: {\n            style: {\n                fill: 'black',\n                fontSize: 14,\n                fontWeight: 600,\n            },\n        },\n        drilldown: {\n            enabled: true,\n            breadCrumb: {\n                rootText: 'Start over',\n                position: 'top-left',\n            },\n        },\n        tooltip: {\n            formatter: (v) => {\n                const root = v.path[v.path.length - 1]\n                return {\n                    name: v.name,\n                    value: `${(v.value / 1000000).toFixed(2)}mb (${((v.value / root.value) * 100).toFixed(2)}%)`,\n                }\n            },\n        },\n    }\n    const [config, setConfig] = useState(defaultConfig)\n\n    const loadData = async () => {\n        try {\n            const res = await fetch('/api/analyze/tables')\n            const resJson = await res.json()\n\n            const filteredRes = resJson.filter((r: { total_bytes: number }) => r.total_bytes > 0)\n            const filteredResUrls = filteredRes\n                .map((fr: { name: string }) => `/api/analyze/${fr.name}/schema`)\n                .slice(0, 1)\n\n            const nestedRes = await Promise.all(\n                filteredResUrls.map((_url: string) => fetch(_url).then((res2) => res2.json()))\n            )\n\n            const configDataChildren = filteredRes.map((table: { name: string; total_bytes: number }) => ({\n                value: table.total_bytes,\n                ...table,\n            }))\n            const configDataChildrenWithDrilldown = configDataChildren.map((child) => {\n                if (nestedRes[0][0].table == child.name) {\n                    const nestedChildren = nestedRes[0].map((nR) => ({\n                        name: nR.column,\n                        category: nR.table,\n                        value: nR.compressed,\n                    }))\n                    return { ...child, children: nestedChildren }\n                }\n                return child\n            })\n            const newConfigData = { ...config.data, children: configDataChildrenWithDrilldown }\n            setConfig({ ...config, data: newConfigData })\n            setSchema(filteredRes)\n        } catch {\n            notification.error({ message: 'Failed to load data' })\n            return\n        }\n    }\n\n    useEffect(() => {\n        loadData()\n    }, [])\n\n    return (\n        <div>\n            <h1 style={{ textAlign: 'left' }}>Schema stats</h1>\n            <h2>Largest tables</h2>\n            <p>\n                Click on the rectangles to get further information about parts and columns for the table. Note that this\n                only covers data stored on the connected node, not the whole cluster.\n            </p>\n            <div style={{ marginBottom: 50 }}>\n                {config.data.children.length < 1 ? (\n                    <Spin />\n                ) : (\n                    <Treemap\n                        {...config}\n                        onEvent={(node, event) => {\n                            if (event.type === 'element:click') {\n                                history.push(`/schema/${event.data.data.name}`)\n                            }\n                        }}\n                        rectStyle={{ cursor: 'pointer ' }}\n                    />\n                )}\n            </div>\n            <div>\n                <h2 style={{ textAlign: 'left' }}>All tables</h2>\n                <Table\n                    dataSource={schema}\n                    onRow={(table, rowIndex) => {\n                        return {\n                            onClick: (event) => {\n                                history.push(`/schema/${table.name}`)\n                            },\n                        }\n                    }}\n                    rowClassName={() => 'cursor-pointer'}\n                    columns={[\n                        { dataIndex: 'name', title: 'Name' },\n                        { dataIndex: 'readable_bytes', title: 'Size', sorter: (a, b) => a.total_bytes - b.total_bytes },\n                        {\n                            dataIndex: 'total_rows',\n                            title: 'Rows',\n                            defaultSortOrder: 'descend',\n                            sorter: (a, b) => a.total_rows - b.total_rows,\n                        },\n                        { dataIndex: 'engine', title: 'Engine' },\n                        { dataIndex: 'partition_key', title: 'Partition Key' },\n                    ]}\n                    loading={config.data.children.length < 1}\n                />\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/SchemaStats/SchemaTable.tsx",
    "content": "// @ts-nocheck\nimport React, { useEffect } from 'react'\nimport { usePollingEffect } from '../../utils/usePollingEffect'\nimport { Treemap } from '@ant-design/charts'\nimport { Table, Tabs, TabsProps, notification } from 'antd'\n\nimport { useHistory } from 'react-router-dom'\n\nfunction TableTreeMap({ schema, dataIndex }) {\n    const config = {\n        data: {\n            name: 'root',\n            children: schema.map((table) => ({ name: table[dataIndex], value: table.compressed, ...table })),\n        },\n        colorField: 'name',\n        style: { cursor: 'pointer' },\n        label: {\n            style: {\n                fill: 'black',\n                fontSize: 14,\n                fontWeight: 600,\n            },\n        },\n        drilldown: {\n            enabled: true,\n            breadCrumb: {\n                rootText: 'Start over',\n            },\n        },\n        tooltip: {\n            formatter: (v) => {\n                const root = v.path[v.path.length - 1]\n                return {\n                    name: v.name,\n                    value: `${(v.value / 1000000).toFixed(2)}mb (percentage: ${((v.value / root.value) * 100).toFixed(\n                        2\n                    )}%)`,\n                }\n            },\n        },\n    }\n\n    return (\n        <div>\n            <Treemap {...config} />\n        </div>\n    )\n}\n\nexport function ColumnsData({ table }: { table: string }): JSX.Element {\n    const [schema, setSchema] = React.useState([])\n\n    const url = `/api/analyze/${table}/schema`\n\n    useEffect\n\n    usePollingEffect(\n        async () =>\n            setSchema(\n                await fetch(url)\n                    .then((response) => {\n                        return response.json()\n                    })\n                    .catch((err) => {\n                        return []\n                    })\n            ),\n        [],\n        { interval: 3000 } // optional\n    )\n\n    const schemaCols = [\n        { dataIndex: 'column', title: 'Name' },\n        { dataIndex: 'type', title: 'type' },\n        { dataIndex: 'compressed_readable', title: 'Compressed' },\n        { dataIndex: 'uncompressed', title: 'Uncompressed' },\n    ]\n\n    return (\n        <>\n            {schema && <TableTreeMap schema={schema} dataIndex=\"column\" />}\n            <div style={{ marginTop: 50 }}>\n                <Table dataSource={schema.map((d) => ({ id: d.column, ...d }))} columns={schemaCols} />\n            </div>\n        </>\n    )\n}\n\nexport function PartsData({ table }: { table: string }): JSX.Element {\n    const [partData, setPartData] = React.useState([])\n\n    const loadData = async () => {\n        try {\n            const res = await fetch(`/api/analyze/${table}/parts`)\n            const resJson = await res.json()\n            setPartData(resJson)\n        } catch {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n\n    useEffect(() => {\n        loadData()\n    }, [])\n\n    const schemaCols = [\n        { dataIndex: 'part', title: 'Name' },\n        { dataIndex: 'compressed_readable', title: 'Compressed' },\n        { dataIndex: 'uncompressed', title: 'Uncompressed' },\n    ]\n\n    return (\n        <>\n            {partData && <TableTreeMap schema={partData} dataIndex=\"part\" />}\n            <div style={{ marginTop: 50 }}>\n                <Table dataSource={partData.map((d) => ({ id: d.part, ...d }))} columns={schemaCols} size=\"middle\" />\n            </div>\n        </>\n    )\n}\n\nexport default function CollapsibleTable({ match }) {\n    const history = useHistory()\n\n    const items: TabsProps['items'] = [\n        {\n            key: 'columns',\n            label: `Columns`,\n            children: <ColumnsData table={match.params.table} />,\n        },\n        {\n            key: 'parts',\n            label: `Parts`,\n            children: <PartsData table={match.params.table} />,\n        },\n    ]\n\n    return (\n        <div>\n            <a onClick={() => history.push(`/schema/`)}>← Return to tables list</a>\n            <h1>Table: {match.params.table}</h1>\n            <Tabs defaultActiveKey=\"columns\" items={items} />\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/SlowQueries/ExampleQueriesTab.tsx",
    "content": "import React, { useEffect, useState } from 'react'\n// @ts-ignore\nimport { highlight, languages } from 'prismjs/components/prism-core' // @ts-ignore\nimport 'prismjs/components/prism-sql'\nimport 'prismjs/components/prism-yaml'\nimport 'prismjs/themes/prism.css'\nimport Editor from 'react-simple-code-editor'\n// @ts-ignore\nimport { Table, notification } from 'antd'\nimport { NoDataSpinner, QueryDetailData } from './QueryDetail'\n\nexport default function ExampleQueriesTab({ query_hash }: { query_hash: string }) {\n    const [data, setData] = useState<{ example_queries: QueryDetailData['example_queries'] } | null>(null)\n\n    const loadData = async () => {\n        try {\n            const res = await fetch(`/api/analyze/${query_hash}/query_examples`)\n            const resJson = await res.json()\n            setData(resJson)\n        } catch {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n\n    useEffect(() => {\n        loadData()\n    }, [])\n\n    return data ? (\n        <Table\n            columns={[\n                {\n                    title: 'Query',\n                    dataIndex: 'query',\n                    render: (_, item) => (\n                        <Editor\n                            value={item.query}\n                            onValueChange={() => {}}\n                            highlight={(code) => highlight(code, languages.sql)}\n                            padding={10}\n                            style={{\n                                fontFamily: '\"Fira code\", \"Fira Mono\", monospace',\n                            }}\n                            disabled\n                        />\n                    ),\n                },\n            ]}\n            dataSource={data.example_queries}\n        />\n    ) : (\n        NoDataSpinner\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/SlowQueries/ExplainTab.tsx",
    "content": "import React, { useEffect, useState } from 'react'\n// @ts-ignore\nimport { highlight, languages } from 'prismjs/components/prism-core' // @ts-ignore\nimport 'prismjs/components/prism-sql'\nimport 'prismjs/components/prism-yaml'\nimport 'prismjs/themes/prism.css'\nimport Editor from 'react-simple-code-editor'\n// @ts-ignore\nimport { NoDataSpinner, QueryDetailData, copyToClipboard } from './QueryDetail'\nimport { notification } from 'antd'\n\nexport default function ExplainTab({ query_hash }: { query_hash: string }) {\n    const [data, setData] = useState<{ explain: QueryDetailData['explain'] } | null>(null)\n\n    const loadData = async () => {\n        try {\n            const res = await fetch(`/api/analyze/${query_hash}/query_explain`)\n            const resJson = await res.json()\n            setData(resJson)\n        } catch {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n\n    useEffect(() => {\n        loadData()\n    }, [])\n\n    return data ? (\n        <div onClick={() => copyToClipboard((data.explain || [{ explain: '' }]).map((row) => row.explain).join('\\n'))}>\n            <Editor\n                value={(data.explain || [{ explain: '' }]).map((row) => row.explain).join('\\n')}\n                onValueChange={() => {}}\n                highlight={(code) => highlight(code, languages.yaml)}\n                padding={10}\n                style={{\n                    fontFamily: '\"Fira code\", \"Fira Mono\", monospace',\n                    fontSize: 12,\n                    border: '1px solid rgb(216, 216, 216)',\n                    borderRadius: 4,\n                    boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)',\n                    marginBottom: 5,\n                }}\n                disabled\n                className=\"code-editor\"\n            />\n        </div>\n    ) : (\n        NoDataSpinner\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/SlowQueries/MetricsTab.tsx",
    "content": "import React, { useEffect, useState } from 'react'\nimport { Line } from '@ant-design/plots'\n// @ts-ignore\nimport { Card, Col, Row, Tooltip, notification } from 'antd'\nimport InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined'\nimport { NoDataSpinner, QueryDetailData } from './QueryDetail'\n\nexport default function MetricsTab({ query_hash }: { query_hash: string }) {\n    const [data, setData] = useState<Omit<QueryDetailData, 'explain' | 'normalized_query' | 'example_queries'> | null>(\n        null\n    )\n\n    const loadData = async () => {\n        try {\n            const res = await fetch(`/api/analyze/${query_hash}/query_metrics`)\n            const resJson = await res.json()\n            setData(resJson)\n        } catch {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n\n    useEffect(() => {\n        loadData()\n    }, [])\n\n    return data ? (\n        <>\n            <br />\n            <Row gutter={8} style={{ paddingBottom: 8 }}>\n                <Col span={12}>\n                    <Card style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)' }} title=\"Number of queries\">\n                        <Line\n                            data={data.execution_count.map(dataPoint => ({\n                                ...dataPoint,\n                                day_start: dataPoint.day_start.split('T')[0],\n                            }))}\n                            xField={'day_start'}\n                            yField={'total'}\n                            xAxis={{ tickCount: 5 }}\n                            style={{ padding: 20, height: 300 }}\n                            color=\"#ffb200\"\n                        />\n                    </Card>\n                </Col>\n                <Col span={12}>\n                    <Card style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)' }} title=\"Data read (GB)\">\n                        <Line\n                            data={data.read_bytes.map(dataPoint => ({\n                                day_start: dataPoint.day_start.split('T')[0],\n                                total: dataPoint.total / 1000000000,\n                            }))}\n                            xField={'day_start'}\n                            yField={'total'}\n                            xAxis={{ tickCount: 5 }}\n                            style={{ padding: 20, height: 300 }}\n                            color=\"#ffb200\"\n                        />\n                    </Card>\n                </Col>\n            </Row>\n            <Row gutter={8}>\n                <Col span={12}>\n                    <Card style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)' }} title=\"Memory usage (GB)\">\n                        <Line\n                            data={data.memory_usage.map(dataPoint => ({\n                                day_start: dataPoint.day_start.split('T')[0],\n                                total: dataPoint.total / 1000000000,\n                            }))}\n                            xField={'day_start'}\n                            yField={'total'}\n                            style={{ padding: 20, height: 300 }}\n                            color=\"#ffb200\"\n                        />\n                    </Card>\n                </Col>\n                <Col span={12}>\n                    <Card\n                        style={{ boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)' }}\n                        title={\n                            <>\n                                CPU usage (seconds){' '}\n                                <Tooltip\n                                    title={`Calculated from OSCPUVirtualTimeMicroseconds metric from ClickHouse query log's ProfileEvents.`}\n                                >\n                                    <span>\n                                        <InfoCircleOutlined />\n                                    </span>\n                                </Tooltip>\n                            </>\n                        }\n                    >\n                        <Line\n                            data={data.cpu.map(dataPoint => ({\n                                day_start: dataPoint.day_start.split('T')[0],\n                                total: dataPoint.total,\n                            }))}\n                            xField={'day_start'}\n                            yField={'total'}\n                            style={{ padding: 20, height: 300 }}\n                            color=\"#ffb200\"\n                        />\n                    </Card>\n                </Col>\n            </Row>\n        </>\n    ) : (\n        NoDataSpinner\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/SlowQueries/NormalizedQueryTab.tsx",
    "content": "import React, { useEffect, useState } from 'react'\n// @ts-ignore\nimport { highlight, languages } from 'prismjs/components/prism-core' // @ts-ignore\nimport 'prismjs/components/prism-sql'\nimport 'prismjs/components/prism-yaml'\nimport 'prismjs/themes/prism.css'\nimport Editor from 'react-simple-code-editor'\n// @ts-ignore\nimport { format } from 'sql-formatter-plus'\nimport { NoDataSpinner, copyToClipboard } from './QueryDetail'\nimport { notification } from 'antd'\n\nexport default function NormalizedQueryTab({ query_hash }: { query_hash: string }) {\n    const [data, setData] = useState<{ query: string } | null>(null)\n\n    const loadData = async () => {\n        try {\n            const res = await fetch(`/api/analyze/${query_hash}/query_normalized`)\n            const resJson = await res.json()\n            setData(resJson)\n        } catch {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n\n    useEffect(() => {\n        loadData()\n    }, [])\n\n    let index = 0\n    return data ? (\n        <div onClick={() => copyToClipboard(data.query)}>\n            <Editor\n                value={format(\n                    data.query.replace(/(\\?)/g, () => {\n                        index = index + 1\n                        return '$' + index\n                    })\n                )}\n                onValueChange={() => {}}\n                highlight={(code) => highlight(code, languages.sql)}\n                padding={10}\n                style={{\n                    fontFamily: '\"Fira code\", \"Fira Mono\", monospace',\n                    fontSize: 16,\n                    border: '1px solid rgb(216, 216, 216)',\n                    borderRadius: 4,\n                    boxShadow: '2px 2px 2px 2px rgb(217 208 208 / 20%)',\n                    marginBottom: 5,\n                }}\n                disabled\n                className=\"code-editor\"\n            />\n        </div>\n    ) : (\n        NoDataSpinner\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/SlowQueries/QueryDetail.tsx",
    "content": "import React, { useEffect, useState } from 'react'\n// @ts-ignore\nimport { Spin, Tabs, TabsProps, notification } from 'antd'\nimport { useHistory } from 'react-router-dom'\nimport NormalizedQueryTab from './NormalizedQueryTab'\nimport MetricsTab from './MetricsTab'\nimport ExplainTab from './ExplainTab'\nimport ExampleQueriesTab from './ExampleQueriesTab'\n\ninterface MetricData {\n    day_start: string\n    total: number\n}\n\nexport interface QueryDetailData {\n    query: string\n    explain: {\n        explain: string\n    }[]\n    example_queries: {\n        query: string\n    }[]\n    execution_count: MetricData[]\n    memory_usage: MetricData[]\n    read_bytes: MetricData[]\n    cpu: MetricData[]\n}\n\nexport const NoDataSpinner = (\n    <div style={{ height: 500 }}>\n        <Spin size=\"large\" style={{ margin: 'auto', display: 'block', marginTop: 50 }} />\n    </div>\n)\n\nexport const copyToClipboard = (value: string) => {\n    notification.info({\n        message: 'Copied to clipboard!',\n        placement: 'bottomRight',\n        duration: 1.5,\n        style: { fontSize: 10 },\n    })\n    navigator.clipboard.writeText(value)\n}\n\nexport default function QueryDetail({ match }: { match: { params: { query_hash: string } } }) {\n    const history = useHistory()\n\n    const items: TabsProps['items'] = [\n        {\n            key: 'query',\n            label: `Query`,\n            children: <NormalizedQueryTab query_hash={match.params.query_hash} />,\n        },\n        {\n            key: 'metrics',\n            label: `Metrics`,\n            children: <MetricsTab query_hash={match.params.query_hash} />,\n        },\n        {\n            key: 'explain',\n            label: `Explain`,\n            children: <ExplainTab query_hash={match.params.query_hash} />,\n        },\n        {\n            key: 'examples',\n            label: `Example queries`,\n            children: <ExampleQueriesTab query_hash={match.params.query_hash} />,\n        },\n    ]\n\n    return (\n        <>\n            <a onClick={() => history.push(`/query_performance/`)}>← Return to queries list</a>\n            <h1>Query analyzer</h1>\n            <Tabs items={items} defaultActiveKey=\"query\" />\n\n            <br />\n            <br />\n        </>\n    )\n}\n"
  },
  {
    "path": "frontend/src/pages/SlowQueries/SlowQueries.tsx",
    "content": "import React, { useEffect, useState } from 'react'\nimport { Select, Table, Typography, notification } from 'antd'\nimport { useHistory } from 'react-router-dom'\nimport { ColumnType } from 'antd/es/table'\nconst { Paragraph } = Typography\n\ninterface SlowQueryData {\n    normalized_query: string\n    normalized_query_hash: string\n    avg_duration: number\n    calls_per_minute: number\n    percentage_iops: number\n    percentage_runtime: number\n    read_bytes: number\n    total_read_bytes: number\n}\n\nexport default function CollapsibleTable() {\n    const [loadingSlowQueries, setLoadingSlowQueries] = useState(false)\n    const [slowQueries, setSlowQueries] = useState<SlowQueryData[]>([])\n    const [timeRange, setTimeRange] = useState('-1w')\n\n    const history = useHistory()\n    const slowQueriesColumns: ColumnType<SlowQueryData>[] = [\n        {\n            title: 'Query',\n            dataIndex: 'normalized_query',\n            key: 'query',\n            render: (_, item) => {\n                let index = 0\n                return (\n                    <Paragraph\n                        className=\"clickable\"\n                        style={{ maxWidth: '100%', fontFamily: 'monospace' }}\n                        ellipsis={{\n                            rows: 2,\n                            expandable: false,\n                        }}\n                    >\n                        {item.normalized_query.replace(/SELECT.*FROM/g, 'SELECT ... FROM').replace(/(\\?)/g, () => {\n                            index = index + 1\n                            return '$' + index\n                        })}\n                    </Paragraph>\n                )\n            },\n        },\n        {\n            title: 'Avg time (ms)',\n            dataIndex: 'avg_duration',\n            defaultSortOrder: 'descend',\n            render: (_, item) => <>{item.avg_duration.toFixed(0)}ms</>,\n            sorter: (a, b) => a.avg_duration - b.avg_duration,\n        },\n        {\n            title: 'Calls / min',\n            dataIndex: 'calls_per_minute',\n            render: (_, item) => <>{item.calls_per_minute.toFixed(3)}</>,\n            sorter: (a, b) => a.calls_per_minute - b.calls_per_minute,\n        },\n        { title: '% of all iops', render: (_, item) => <>{item.percentage_iops.toFixed(1)}%</> },\n        {\n            title: '% of runtime',\n            render: (_, item) => <>{item.percentage_runtime.toFixed(1)}%</>,\n            dataIndex: 'percentage_runtime',\n            sorter: (a, b) => a.percentage_runtime - b.percentage_runtime,\n        },\n        {\n            title: 'Total iops',\n            dataIndex: 'total_read_bytes',\n            sorter: (a, b) => a.total_read_bytes - b.total_read_bytes,\n        },\n    ]\n\n    const loadData = async (timeRange = '-1w') => {\n        setSlowQueries([])\n        setLoadingSlowQueries(true)\n        try {\n            const res = await fetch(`/api/analyze/slow_queries?time_range=${timeRange}`)\n            const resJson = await res.json()\n            const slowQueriesData = resJson.map((error: SlowQueryData, idx: number) => ({ key: idx, ...error }))\n            setSlowQueries(slowQueriesData)\n            setLoadingSlowQueries(false)\n        } catch {\n            notification.error({ message: 'Failed to load data' })\n        }\n    }\n\n    useEffect(() => {\n        loadData()\n    }, [])\n\n    return (\n        <div>\n            <h1 style={{ textAlign: 'left' }}>Query performance</h1>\n            <p>Click on queries to display more details.</p>\n            <div>\n                <Select\n                    placeholder=\"system.query_log\"\n                    optionFilterProp=\"children\"\n                    options={[\n                        { label: 'Last week', value: '-1w' },\n                        { label: 'Last two weeks', value: '-2w' },\n                        { label: 'Last month', value: '-1m' },\n                        { label: 'Last three months', value: '-3m' }\n                    ]}\n                    style={{ width: 200, float: 'right', marginBottom: 4 }}\n                    onChange={(value) => {\n                        setTimeRange(value)\n                        loadData(value)\n                    }}\n                    showSearch={false}\n                    value={timeRange}\n                />\n                <Table\n                    columns={slowQueriesColumns}\n                    onRow={(query, _) => {\n                        return {\n                            onClick: () => {\n                                history.push(`/query_performance/${query.normalized_query_hash}`)\n                            },\n                        }\n                    }}\n                    rowClassName={() => 'cursor-pointer'}\n                    dataSource={slowQueries}\n                    loading={loadingSlowQueries}\n                    size=\"small\"\n                />\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/react-app-env.d.ts",
    "content": "/// <reference types=\"react-scripts\" />\n"
  },
  {
    "path": "frontend/src/utils/dateUtils.ts",
    "content": "\nconst monthNames = [\"January\", \"February\", \"March\", \"April\", \"May\", \"June\",\n\"July\", \"August\", \"September\", \"October\", \"November\", \"December\"\n]\n\nexport function isoTimestampToHumanReadable(isoDate: string): string {\n    const date = new Date(isoDate)\n\n    // no need to display the year if it's this year\n    const year = new Date().getFullYear() === date.getFullYear() ? '' : `, ${date.getFullYear()}`\n\n    // Prepare the date format\n    const formattedDate = monthNames[date.getMonth()] + ' '\n        + date.getDate()\n        + year + ' '\n        + ('0' + date.getHours()).slice(-2) + ':'\n        + ('0' + date.getMinutes()).slice(-2)\n\n    return formattedDate\n}\n"
  },
  {
    "path": "frontend/src/utils/usePollingEffect.tsx",
    "content": "import { useEffect, useRef } from 'react'\n\nexport function usePollingEffect(\n    asyncCallback: any,\n    dependencies = [],\n    {\n        interval = 3000, // 3 seconds,\n        onCleanUp = () => {},\n    } = {}\n) {\n    const timeoutIdRef = useRef<number | null>(null)\n    useEffect(() => {\n        let _stopped = false\n        ;(async function pollingCallback() {\n            try {\n                await asyncCallback()\n            } finally {\n                // Set timeout after it finished, unless stopped\n                timeoutIdRef.current = !_stopped && window.setTimeout(pollingCallback, interval)\n            }\n        })()\n        // Clean up if dependencies change\n        return () => {\n            _stopped = true // prevent racing conditions\n            if (timeoutIdRef.current) {\n                clearTimeout(timeoutIdRef.current)\n            }\n            onCleanUp()\n        }\n    }, [...dependencies, interval])\n}\n"
  },
  {
    "path": "frontend/tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"target\": \"es5\",\n        \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n        \"allowJs\": true,\n        \"skipLibCheck\": true,\n        \"esModuleInterop\": true,\n        \"allowSyntheticDefaultImports\": true,\n        \"strict\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"noFallthroughCasesInSwitch\": true,\n        \"module\": \"esnext\",\n        \"moduleResolution\": \"node\",\n        \"resolveJsonModule\": true,\n        \"isolatedModules\": true,\n        \"noEmit\": true,\n        \"jsx\": \"preserve\"\n    },\n    \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "frontend/vite.config.ts",
    "content": "import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n    plugins: [react()],\n    server: {\n        proxy: {\n            \"/api\": {\n                target: \"http://127.0.0.1:8000\",\n                secure: false,\n                ws: true,\n            },\n            \"/admin/\": {\n                target: \"http://127.0.0.1:8000\",\n                secure: false,\n                ws: true,\n            },\n            \"/logout\": {\n                target: \"http://127.0.0.1:8000\",\n                secure: false,\n                ws: true,\n            },\n        },\n    },\n    base: \"/\",\n    build: {\n        outDir: \"./build\"\n    }\n})\n"
  },
  {
    "path": "housewatch/__init__.py",
    "content": ""
  },
  {
    "path": "housewatch/admin/__init__.py",
    "content": "from django.contrib import admin\n\nfrom housewatch.models.preferred_replica import PreferredReplica\n\n\nadmin.site.register(PreferredReplica)\n"
  },
  {
    "path": "housewatch/admin.py",
    "content": "from django.utils.html import format_html\n\n\ndef html_link(url, text, new_tab=False):\n    if new_tab:\n        return format_html('<a href=\"{}\" target=\"_blank\">{}</a>', url, text)\n    return format_html('<a href=\"{}\">{}</a>', url, text)\n\n\ndef error_span(text):\n    return format_html('<span style=\"color: red\">{}</span>', text)\n"
  },
  {
    "path": "housewatch/ai/templates.py",
    "content": "NATURAL_LANGUAGE_QUERY_SYSTEM_PROMPT = \"\"\"\nYou are a program that turns natural language queries into valid ClickHouse SQL. You do not have conversational abilities.\n\nGiven a prompt, you will reply with the appropriate SQL.\n\nThe prompts you will receive will ALWAYS come following this structure:\n\n```\n# Tables to query\n\n## <table1 name>\n\n<table1 schema>\n\n## <table2 name>\n\n<table2 schema>\n\n# Query\n\n<user's natural language query>\n```\n\nYour responses must ALWAYS be plain JSON with the following structure:\n\n```json\n{\n\t\"sql\": \"<generated ClickHouse SQL for prompt>\",\n\t\"error\": \"<an error message if you cannot generate the SQL, defaults to null>\"\n}\n```\n\nExample prompt:\n\n```\n# Tables to query\n\n## users\n\nCREATE TABLE users (uid Int16, created_at DateTime64) ENGINE=Memory\n\n## user_metadata\n\nCREATE TABLE user_metadata (uid Int16, metadata String) ENGINE=Memory\n\n# Query\n\ngive me the ID and metadata of users created in the last hour\n```\n\nExample response:\n\n```json\n{\n\t\"sql\": \"SELECT users.uid, user_metadata.metadata FROM users JOIN user_metadata ON users.uid = user_metadata.uid WHERE created_at > now() - INTERVAL 1 HOUR\",\n\t\"error\": null\n}\n```\n\nRules:\n\n- You must only query valid columns from the tables specified under `tables_to_query`. However, you do not always need to query all the provided tables. If more than one table is provided, consider how the user may want a JOIN between some or all of the tables, but not always.\n- Do not include any characters such as `\\t` and `\\n` in the SQL\n\"\"\"\n\nNATURAL_LANGUAGE_QUERY_USER_PROMPT = \"\"\"\n# Tables to query\n\n%(tables_to_query)s\n\n# Query\n\n%(query)s\n\n\"\"\"\n\nTABLE_PROMPT = \"\"\"\n# {database}.{table}\n\n{create_table_query}\n\"\"\"\n"
  },
  {
    "path": "housewatch/api/__init__.py",
    "content": ""
  },
  {
    "path": "housewatch/api/analyze.py",
    "content": "from rest_framework.viewsets import GenericViewSet\nfrom rest_framework.request import Request\nfrom rest_framework.response import Response\nfrom rest_framework.decorators import action\n\nfrom django.conf import settings\n\nfrom housewatch.clickhouse.client import run_query, existing_system_tables\nfrom housewatch.clickhouse.queries.sql import (\n    SLOW_QUERIES_SQL,\n    SCHEMA_SQL,\n    QUERY_EXECUTION_COUNT_SQL,\n    QUERY_LOAD_SQL,\n    ERRORS_SQL,\n    QUERY_MEMORY_USAGE_SQL,\n    QUERY_READ_BYTES_SQL,\n    RUNNING_QUERIES_SQL,\n    KILL_QUERY_SQL,\n    PARTS_SQL,\n    NODE_STORAGE_SQL,\n    GET_QUERY_BY_NORMALIZED_HASH_SQL,\n    QUERY_CPU_USAGE_SQL,\n    LOGS_SQL,\n    LOGS_FREQUENCY_SQL,\n    EXPLAIN_QUERY,\n    BENCHMARKING_SQL,\n    AVAILABLE_TABLES_SQL,\n    TABLE_SCHEMAS_SQL,\n)\nfrom uuid import uuid4\nimport json\nfrom time import sleep\nimport os\nimport openai\nfrom housewatch.ai.templates import (\n    NATURAL_LANGUAGE_QUERY_SYSTEM_PROMPT,\n    NATURAL_LANGUAGE_QUERY_USER_PROMPT,\n    TABLE_PROMPT,\n)\n\nopenai.api_key = os.getenv(\"OPENAI_API_KEY\")\nOPENAI_MODEL = os.getenv(\"OPENAI_MODEL\", \"gpt-3.5-turbo\")\n\nDEFAULT_DAYS = 7\n\nTIME_RANGE_TO_CLICKHOUSE_INTERVAL = {\n    \"-1w\": \"INTERVAL 1 WEEK\",\n    \"-2w\": \"INTERVAL 2 WEEK\",\n    \"-1m\": \"INTERVAL 1 MONTH\",\n    \"-3m\": \"INTERVAL 3 MONTH\",\n}\n\n\nclass AnalyzeViewset(GenericViewSet):\n    def list(self, request: Request) -> Response:\n        pass\n\n    @action(detail=False, methods=[\"GET\"])\n    def slow_queries(self, request: Request):\n        ch_interval = TIME_RANGE_TO_CLICKHOUSE_INTERVAL[request.GET.get(\"time_range\", \"-1w\")]\n        params = {\"limit\": 100, \"date_from\": f\"now() - {ch_interval}\"}\n        query_result = run_query(SLOW_QUERIES_SQL, params)\n        return Response(query_result)\n\n    @action(detail=True, methods=[\"GET\"])\n    def query_normalized(self, request: Request, pk: str):\n        query_details = run_query(GET_QUERY_BY_NORMALIZED_HASH_SQL, {\"normalized_query_hash\": pk})\n        normalized_query = query_details[0][\"normalized_query\"]\n\n        return Response(\n            {\n                \"query\": normalized_query,\n            }\n        )\n\n    @action(detail=True, methods=[\"GET\"])\n    def query_metrics(self, request: Request, pk: str):\n        days = request.GET.get(\"days\", DEFAULT_DAYS)\n        conditions = \"AND event_time > now() - INTERVAL 1 WEEK AND toString(normalized_query_hash) = '{}'\".format(pk)\n        execution_count = run_query(QUERY_EXECUTION_COUNT_SQL, {\"days\": days, \"conditions\": conditions})\n        memory_usage = run_query(QUERY_MEMORY_USAGE_SQL, {\"days\": days, \"conditions\": conditions})\n        read_bytes = run_query(QUERY_READ_BYTES_SQL, {\"days\": days, \"conditions\": conditions})\n        cpu = run_query(QUERY_CPU_USAGE_SQL, {\"days\": days, \"conditions\": conditions})\n\n        return Response(\n            {\"execution_count\": execution_count, \"memory_usage\": memory_usage, \"read_bytes\": read_bytes, \"cpu\": cpu}\n        )\n\n    @action(detail=True, methods=[\"GET\"])\n    def query_explain(self, request: Request, pk: str):\n        query_details = run_query(GET_QUERY_BY_NORMALIZED_HASH_SQL, {\"normalized_query_hash\": pk})\n        example_queries = query_details[0][\"example_queries\"]\n        explain = run_query(EXPLAIN_QUERY, {\"query\": example_queries[0]})\n\n        return Response(\n            {\n                \"explain\": explain,\n            }\n        )\n\n    @action(detail=True, methods=[\"GET\"])\n    def query_examples(self, request: Request, pk: str):\n        query_details = run_query(GET_QUERY_BY_NORMALIZED_HASH_SQL, {\"normalized_query_hash\": pk})\n        example_queries = query_details[0][\"example_queries\"]\n\n        return Response(\n            {\n                \"example_queries\": [{\"query\": q} for q in example_queries],\n            }\n        )\n\n    @action(detail=False, methods=[\"GET\"])\n    def query_graphs(self, request: Request):\n        days = request.GET.get(\"days\", DEFAULT_DAYS)\n        execution_count = run_query(QUERY_EXECUTION_COUNT_SQL, {\"days\": days, \"conditions\": \"\"})\n        memory_usage = run_query(QUERY_MEMORY_USAGE_SQL, {\"days\": days, \"conditions\": \"\"})\n        read_bytes = run_query(QUERY_READ_BYTES_SQL, {\"days\": days, \"conditions\": \"\"})\n        cpu = run_query(QUERY_CPU_USAGE_SQL, {\"days\": days, \"conditions\": \"\"})\n        return Response(\n            {\"execution_count\": execution_count, \"memory_usage\": memory_usage, \"read_bytes\": read_bytes, \"cpu\": cpu}\n        )\n\n    @action(detail=False, methods=[\"POST\"])\n    def logs(self, request: Request):\n        if \"text_log\" not in existing_system_tables:\n            return Response(status=418, data={\"error\": \"text_log table does not exist\"})\n        query_result = run_query(\n            LOGS_SQL, {\"message\": f\"%{request.data['message_ilike']}%\" if request.data[\"message_ilike\"] else \"%\"}\n        )\n        return Response(query_result)\n\n    @action(detail=False, methods=[\"POST\"])\n    def logs_frequency(self, request: Request):\n        if \"text_log\" not in existing_system_tables:\n            return Response(status=418, data={\"error\": \"text_log table does not exist\"})\n        query_result = run_query(\n            LOGS_FREQUENCY_SQL,\n            {\"message\": f\"%{request.data['message_ilike']}%\" if request.data[\"message_ilike\"] else \"%\"},\n        )\n        return Response(query_result)\n\n    @action(detail=False, methods=[\"POST\"])\n    def query(self, request: Request):\n        query_id = request.data[\"query_id\"] if \"query_id\" in request.data else None\n        try:\n            query_result = run_query(request.data[\"sql\"], query_id=query_id, use_cache=False, substitute_params=False)\n        except Exception as e:\n            return Response(status=418, data={\"error\": str(e)})\n        return Response({\"result\": query_result})\n\n    @action(detail=False, methods=[\"GET\"])\n    def hostname(self, request: Request):\n        return Response({\"hostname\": settings.CLICKHOUSE_HOST})\n\n    @action(detail=True, methods=[\"GET\"])\n    def schema(self, request: Request, pk: str):\n        query_result = run_query(SCHEMA_SQL, {\"table\": pk})\n        return Response(query_result)\n\n    @action(detail=True, methods=[\"GET\"])\n    def parts(self, request: Request, pk: str):\n        query_result = run_query(PARTS_SQL, {\"table\": pk})\n        return Response(query_result)\n\n    @action(detail=False, methods=[\"GET\"])\n    def query_load(self, request: Request):\n        params = {\n            \"column_alias\": \"average_query_duration\",\n            \"math_func\": \"avg\",\n            \"load_metric\": \"query_duration_ms\",\n            \"date_to\": \"now()\",\n            \"date_from\": \"now() - INTERVAL 2 WEEK\",\n        }\n\n        query_result = run_query(QUERY_LOAD_SQL, params)\n\n        return Response(query_result)\n\n    @action(detail=False, methods=[\"GET\"])\n    def errors(self, request: Request):\n        params = {\"date_from\": \"now() - INTERVAL 2 WEEK\"}\n\n        query_result = run_query(ERRORS_SQL, params)\n\n        return Response(query_result)\n\n    @action(detail=False, methods=[\"GET\"])\n    def running_queries(self, request: Request):\n        query_result = run_query(RUNNING_QUERIES_SQL, use_cache=False)\n\n        return Response(query_result)\n\n    @action(detail=True, methods=[\"POST\"])\n    def kill_query(self, request: Request, pk: str):\n        query_result = run_query(KILL_QUERY_SQL, {\"query_id\": request.data[\"query_id\"]}, use_cache=False)\n        return Response(query_result)\n\n    @action(detail=False, methods=[\"GET\"])\n    def cluster_overview(self, request: Request):\n        storage_query_result = run_query(NODE_STORAGE_SQL, {})\n\n        full_result = []\n        for i in range(len(storage_query_result)):\n            node_result = {**storage_query_result[i]}\n            full_result.append(node_result)\n\n        return Response(full_result)\n\n    @action(detail=False, methods=[\"POST\"])\n    def benchmark(self, request: Request):\n        query1_tag = f\"benchmarking_q1_{str(uuid4())}\"\n        query2_tag = f\"benchmarking_q2_{str(uuid4())}\"\n\n        error_location = None\n        # we use min_bytes_to_use_direct_io to try to not use the page cache\n        # docs: https://clickhouse.com/docs/en/operations/settings/settings#settings-min-bytes-to-use-direct-io\n        # it's unclear how well this works so this needs digging (see https://github.com/ClickHouse/ClickHouse/issues/36301)\n        try:\n            error_location = \"Control\"\n            query1_result = run_query(\n                request.data[\"query1\"],\n                settings={\"log_comment\": query1_tag, \"min_bytes_to_use_direct_io\": 1},\n                use_cache=False,\n                substitute_params=False,\n            )\n\n            error_location = \"Test\"\n            query2_result = run_query(\n                request.data[\"query2\"],\n                settings={\"log_comment\": query2_tag, \"min_bytes_to_use_direct_io\": 1},\n                use_cache=False,\n                substitute_params=False,\n            )\n\n            error_location = \"benchmark\"\n            is_result_equal = json.dumps(query1_result, default=str) == json.dumps(query2_result, default=str)\n\n            # make sure the query log populates\n            run_query(\"SYSTEM FLUSH LOGS\")\n            benchmarking_result = []\n            i = 0\n            while len(benchmarking_result) == 0 and i < 10:\n                benchmarking_result = run_query(\n                    BENCHMARKING_SQL, params={\"query1_tag\": query1_tag, \"query2_tag\": query2_tag}, use_cache=False\n                )\n                i += 1\n                sleep(0.5)\n            return Response({\"is_result_equal\": is_result_equal, \"benchmarking_result\": benchmarking_result})\n        except Exception as e:\n            return Response(status=418, data={\"error\": str(e), \"error_location\": error_location})\n\n    @action(detail=False, methods=[\"GET\"])\n    def ai_tools_available(self, request: Request):\n        openai_api_key = os.getenv(\"OPENAI_API_KEY\")\n        if not openai_api_key:\n            return Response(\n                status=400,\n                data={\n                    \"error\": \"OPENAI_API_KEY not set. To use the AI toolset you must pass in an OpenAI API key via the OPENAI_API_KEY environment variable.\"\n                },\n            )\n        return Response({\"status\": \"ok\"})\n\n    @action(detail=False, methods=[\"GET\"])\n    def tables(self, request: Request):\n        query_result = run_query(AVAILABLE_TABLES_SQL, use_cache=False)\n        return Response(query_result)\n\n    @action(detail=False, methods=[\"POST\"])\n    def natural_language_query(self, request: Request):\n        table_schema_sql_conditions = []\n        for full_table_name in request.data[\"tables_to_query\"]:\n            database, table = full_table_name.split(\">>>>>\")\n            condition = f\"(database = '{database}' AND table = '{table}')\"\n            table_schema_sql_conditions.append(condition)\n\n        table_schemas = run_query(TABLE_SCHEMAS_SQL, {\"conditions\": \" OR \".join(table_schema_sql_conditions)})\n\n        user_prompt_tables = \"\"\n        for row in table_schemas:\n            user_prompt_tables += TABLE_PROMPT.format(\n                database=row[\"database\"], table=row[\"table\"], create_table_query=row[\"create_table_query\"]\n            )\n\n        final_user_prompt = NATURAL_LANGUAGE_QUERY_USER_PROMPT % {\n            \"tables_to_query\": user_prompt_tables,\n            \"query\": request.data[\"query\"],\n        }\n\n        try:\n            completion = openai.ChatCompletion.create(\n                model=OPENAI_MODEL,\n                messages=[\n                    {\"role\": \"system\", \"content\": NATURAL_LANGUAGE_QUERY_SYSTEM_PROMPT},\n                    {\"role\": \"user\", \"content\": final_user_prompt},\n                ],\n            )\n        except Exception as e:\n            return Response(status=418, data={\"error\": str(e), \"sql\": None})\n\n        response_json = json.loads(completion.choices[0].message[\"content\"])\n        sql = response_json[\"sql\"]\n        error = response_json[\"error\"]\n        if error:\n            return Response(status=418, data={\"error\": error, \"sql\": sql})\n\n        settings = {\"readonly\": 1} if request.data.get(\"readonly\", False) else {}\n\n        try:\n            query_result = run_query(sql, use_cache=False, substitute_params=False, settings=settings)\n            return Response({\"result\": query_result, \"sql\": sql, \"error\": None})\n        except Exception as e:\n            return Response(status=418, data={\"error\": str(e), \"sql\": sql})\n"
  },
  {
    "path": "housewatch/api/async_migration.py",
    "content": "import structlog\nfrom rest_framework import serializers, viewsets\nfrom rest_framework.decorators import action\nfrom housewatch.models.async_migration import AsyncMigration, MigrationStatus\nfrom housewatch.celery import run_async_migration\nfrom rest_framework.response import Response\n\n\nlogger = structlog.get_logger(__name__)\n\n\nclass AsyncMigrationSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = AsyncMigration\n        fields = [\n            \"id\",\n            \"name\",\n            \"description\",\n            \"progress\",\n            \"status\",\n            \"current_operation_index\",\n            \"current_query_id\",\n            \"task_id\",\n            \"started_at\",\n            \"finished_at\",\n            \"operations\",\n            \"rollback_operations\",\n            \"last_error\",\n        ]\n        read_only_fields = [\n            \"id\",\n            \"progress\",\n            \"status\",\n            \"current_operation_index\",\n            \"current_query_id\",\n            \"task_id\",\n            \"started_at\",\n            \"finished_at\",\n            \"last_error\",\n        ]\n\n    def create(self, validated_data):\n        validated_data[\"progress\"] = 0\n        validated_data[\"current_operation_index\"] = 0\n        validated_data[\"status\"] = MigrationStatus.NotStarted\n        return super().create(validated_data)\n\n\nclass AsyncMigrationsViewset(viewsets.ModelViewSet):\n    queryset = AsyncMigration.objects.all().order_by(\"name\")\n    serializer_class = AsyncMigrationSerializer\n\n    @action(methods=[\"POST\"], detail=True)\n    def trigger(self, request, **kwargs):\n\n        migration = self.get_object()\n\n        migration.status = MigrationStatus.Starting\n        migration.save()\n\n        run_async_migration.delay(migration.name)\n        return Response({\"success\": True}, status=200)\n\n    # @action(methods=[\"GET\"], detail=False)\n    # def test(self, request, **kwargs):\n    #     simple.delay()\n    #     return Response()\n\n    # def _force_stop(self, rollback: bool):\n    #     migration = self.get_object()\n    #     if migration.status not in [MigrationStatus.Running, MigrationStatus.Starting]:\n    #         return response.Response(\n    #             {\"success\": False, \"error\": \"Can't stop a migration that isn't running.\"}, status=400\n    #         )\n    #     force_stop_migration(migration, rollback=rollback)\n    #     return response.Response({\"success\": True}, status=200)\n\n    # # DANGEROUS! Can cause another task to be lost\n    # @action(methods=[\"POST\"], detail=True)\n    # def force_stop(self, request, **kwargs):\n    #     return self._force_stop(rollback=True)\n\n    # # DANGEROUS! Can cause another task to be lost\n    # @action(methods=[\"POST\"], detail=True)\n    # def force_stop_without_rollback(self, request, **kwargs):\n    #     return self._force_stop(rollback=False)\n\n    # @action(methods=[\"POST\"], detail=True)\n    # def rollback(self, request, **kwargs):\n    #     migration = self.get_object()\n    #     if migration.status != MigrationStatus.Errored:\n    #         return response.Response(\n    #             {\"success\": False, \"error\": \"Can't rollback a migration that isn't in errored state.\"}, status=400\n    #         )\n\n    #     rollback_migration(migration)\n    #     return response.Response({\"success\": True}, status=200)\n\n    # @action(methods=[\"POST\"], detail=True)\n    # def force_rollback(self, request, **kwargs):\n    #     migration = self.get_object()\n    #     if migration.status != MigrationStatus.CompletedSuccessfully:\n    #         return response.Response(\n    #             {\"success\": False, \"error\": \"Can't force rollback a migration that did not complete successfully.\"},\n    #             status=400,\n    #         )\n\n    #     rollback_migration(migration)\n    #     return response.Response({\"success\": True}, status=200)\n\n    # @action(methods=[\"GET\"], detail=True)\n    # def errors(self, request, **kwargs):\n    #     migration = self.get_object()\n    #     return response.Response(\n    #         [\n    #             AsyncMigrationErrorsSerializer(e).data\n    #             for e in AsyncMigrationError.objects.filter(async_migration=migration).order_by(\"-created_at\")\n    #         ]\n    #     )\n"
  },
  {
    "path": "housewatch/api/backups.py",
    "content": "import structlog\nfrom croniter import croniter\nfrom rest_framework.decorators import action\nfrom rest_framework.request import Request\nfrom rest_framework.response import Response\nfrom rest_framework import serializers\nfrom rest_framework.viewsets import ModelViewSet, GenericViewSet\nfrom housewatch.clickhouse import backups\nfrom housewatch.models.backup import ScheduledBackup\n\nlogger = structlog.get_logger(__name__)\n\n\nclass BackupViewset(GenericViewSet):\n    def list(self, request: Request) -> Response:\n        cluster = request.query_params.get(\"cluster\")\n        return Response(backups.get_backups(cluster=cluster))\n\n    def retrieve(self, request: Request, pk: str) -> Response:\n        cluster = request.query_params.get(\"cluster\")\n        return Response(backups.get_backup(pk, cluster=cluster))\n\n    @action(detail=True, methods=[\"post\"])\n    def restore(self, request: Request, pk: str) -> Response:\n        backups.restore_backup(pk)\n        return Response()\n\n    def create(self, request: Request) -> Response:\n        database = request.data.get(\"database\")\n        table = request.data.get(\"table\")\n        bucket = request.data.get(\"bucket\")\n        path = request.data.get(\"path\")\n        if table:\n            res = backups.create_table_backup(database, table, bucket, path)\n        else:\n            res = backups.create_database_backup(database, bucket, path)\n        return Response(res)\n\n\nclass ScheduledBackupSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = ScheduledBackup\n        fields = \"__all__\"\n        read_only_fields = [\"id\", \"last_run_time\"]\n\n    def validate(self, data):\n        if data.get(\"schedule\") and not croniter.is_valid(data[\"schedule\"]):\n            raise serializers.ValidationError(f\"Invalid cron expression: {e}\")\n        if data.get(\"incremental_schedule\") and not croniter.is_valid(data[\"incremental_schedule\"]):\n            raise serializers.ValidationError(f\"Invalid cron expression: {e}\")\n        return data\n\n\nclass ScheduledBackupViewset(ModelViewSet):\n    queryset = ScheduledBackup.objects.all().order_by(\"created_at\")\n    serializer_class = ScheduledBackupSerializer\n\n    @action(detail=True, methods=[\"post\"])\n    def run(self, request: Request, pk: str) -> Response:\n        uuid = backups.run_backup(pk)\n        return Response({\"backup_uuid\": uuid})\n"
  },
  {
    "path": "housewatch/api/cluster.py",
    "content": "import structlog\nfrom rest_framework.decorators import action\nfrom rest_framework.request import Request\nfrom rest_framework.response import Response\nfrom rest_framework.viewsets import GenericViewSet\nfrom housewatch.clickhouse import clusters\n\n\nlogger = structlog.get_logger(__name__)\n\n\nclass ClusterViewset(GenericViewSet):\n    def list(self, request: Request) -> Response:\n        return Response(clusters.get_clusters())\n\n    def retrieve(self, request: Request, pk: str) -> Response:\n        return Response(clusters.get_cluster(pk))\n"
  },
  {
    "path": "housewatch/api/instance.py",
    "content": "import structlog\nfrom rest_framework.decorators import action\nfrom rest_framework.request import Request\nfrom rest_framework.response import Response\nfrom rest_framework.viewsets import ModelViewSet\nfrom rest_framework.serializers import ModelSerializer\nfrom housewatch.models import Instance\nfrom housewatch.clickhouse import clusters\n\n\nlogger = structlog.get_logger(__name__)\n\n\nclass InstanceSerializer(ModelSerializer):\n    class Meta:\n        model = Instance\n        fields = [\"id\", \"created_at\", \"username\", \"host\", \"port\"]\n\n\nclass InstanceViewset(ModelViewSet):\n    queryset = Instance.objects.all()\n    serializer_class = InstanceSerializer\n"
  },
  {
    "path": "housewatch/api/saved_queries.py",
    "content": "import structlog\nfrom rest_framework import serializers, viewsets\nfrom housewatch.models.saved_queries import SavedQuery\n\n\nlogger = structlog.get_logger(__name__)\n\n\nclass SavedQuerySerializer(serializers.ModelSerializer):\n    class Meta:\n        model = SavedQuery\n        fields = [\"id\", \"name\", \"query\", \"created_at\"]\n        read_only_fields = [\"id\", \"created_at\"]\n\n\nclass SavedQueryViewset(viewsets.ModelViewSet):\n    queryset = SavedQuery.objects.all().order_by(\"name\")\n    serializer_class = SavedQuerySerializer\n"
  },
  {
    "path": "housewatch/apps.py",
    "content": "from django.apps import AppConfig\n\n\nclass HouseWatchConfig(AppConfig):\n    name = \"housewatch\"\n    verbose_name = \"HouseWatch\"\n"
  },
  {
    "path": "housewatch/asgi.py",
    "content": "\"\"\"\nASGI config for billing project.\n\nIt exposes the ASGI callable as a module-level variable named ``application``.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/4.1/howto/deployment/asgi/\n\"\"\"\n\nimport os\n\nfrom django.core.asgi import get_asgi_application\n\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"housewatch.settings\")\n\napplication = get_asgi_application()\n"
  },
  {
    "path": "housewatch/async_migrations/__init__.py",
    "content": ""
  },
  {
    "path": "housewatch/async_migrations/async_migration_utils.py",
    "content": "from datetime import datetime\nfrom typing import Optional\n\nimport structlog\nfrom django.db import transaction\nfrom django.utils.timezone import now\nfrom housewatch.models.async_migration import AsyncMigration, MigrationStatus\n\n\nlogger = structlog.get_logger(__name__)\n\n\n# def execute_op(op: AsyncMigrationOperation, uuid: str, rollback: bool = False):\n#     \"\"\"\n#     Execute the fn or rollback_fn\n#     \"\"\"\n#     op.rollback_fn(uuid) if rollback else op.fn(uuid)\n\n\ndef execute_op(sql: str, args=None, *, query_id: str, timeout_seconds: int = 600, settings=None):\n    from housewatch.clickhouse.client import run_query\n\n    settings = settings if settings else {\"max_execution_time\": timeout_seconds, \"log_comment\": query_id}\n\n    try:\n        run_query(sql, args, settings=settings, use_cache=False)\n    except Exception as e:\n        raise Exception(f\"Failed to execute ClickHouse op: sql={sql},\\nquery_id={query_id},\\nexception={str(e)}\") from e\n\n\ndef mark_async_migration_as_running(migration: AsyncMigration) -> bool:\n    # update to running iff the state was Starting (ui triggered) or NotStarted (api triggered)\n    with transaction.atomic():\n        if migration.status not in [MigrationStatus.Starting, MigrationStatus.NotStarted]:\n            return False\n        migration.status = MigrationStatus.Running\n        migration.current_query_id = \"\"\n        migration.progress = 0\n        migration.current_operation_index = 0\n        migration.started_at = now()\n        migration.finished_at = None\n        migration.save()\n    return True\n\n\ndef halt_starting_migration(migration: AsyncMigration) -> bool:\n    # update to RolledBack (which blocks starting a migration) iff the state was Starting\n    with transaction.atomic():\n        instance = AsyncMigration.objects.select_for_update().get(pk=migration.pk)\n        if instance.status != MigrationStatus.Starting:\n            return False\n        instance.status = MigrationStatus.RolledBack\n        instance.save()\n    return True\n\n\ndef update_async_migration(\n    migration: AsyncMigration,\n    last_error: Optional[str] = None,\n    current_query_id: Optional[str] = None,\n    task_id: Optional[str] = None,\n    progress: Optional[int] = None,\n    current_operation_index: Optional[int] = None,\n    status: Optional[int] = None,\n    started_at: Optional[datetime] = None,\n    finished_at: Optional[datetime] = None,\n    lock_row: bool = True,\n):\n    def execute_update():\n        instance = migration\n        if lock_row:\n            instance = AsyncMigration.objects.select_for_update().get(pk=migration.pk)\n        else:\n            instance.refresh_from_db()\n        if current_query_id is not None:\n            instance.current_query_id = current_query_id\n        if last_error is not None:\n            instance.last_error = last_error\n        if task_id is not None:\n            instance.task_id = task_id\n        if progress is not None:\n            instance.progress = progress\n        if current_operation_index is not None:\n            instance.current_operation_index = current_operation_index\n        if status is not None:\n            instance.status = status\n        if started_at is not None:\n            instance.started_at = started_at\n        if finished_at is not None:\n            instance.finished_at = finished_at\n        instance.save()\n\n    if lock_row:\n        with transaction.atomic():\n            execute_update()\n    else:\n        execute_update()\n\n\ndef process_error(\n    migration: AsyncMigration,\n    last_error: str,\n    rollback: bool = True,\n    status: int = MigrationStatus.Errored,\n    current_operation_index: Optional[int] = None,\n):\n    logger.error(f\"Async migration {migration.name} error: {last_error}\")\n\n    update_async_migration(\n        migration=migration,\n        current_operation_index=current_operation_index,\n        status=status,\n        last_error=last_error,\n        finished_at=now(),\n    )\n\n    if not rollback or status == MigrationStatus.FailedAtStartup:\n        return\n\n    from housewatch.async_migrations.runner import attempt_migration_rollback\n\n    attempt_migration_rollback(migration)\n\n\ndef trigger_migration(migration: AsyncMigration, fresh_start: bool = True):\n    from housewatch.tasks import run_async_migration\n\n    task = run_async_migration.delay(migration.name, fresh_start)\n\n    update_async_migration(migration=migration, task_id=str(task.id))\n\n\n# def force_stop_migration(\n#     migration: AsyncMigration, error: str = \"Force stopped by user\", rollback: bool = True\n# ):\n#     \"\"\"\n#     In theory this is dangerous, as it can cause another task to be lost\n#     `revoke` with `terminate=True` kills the process that's working on the task\n#     and there's no guarantee the task will not already be done by the time this happens.\n#     See: https://docs.celeryproject.org/en/stable/reference/celery.app.control.html#celery.app.control.Control.revoke\n#     However, this is generally ok for us because:\n#     1. Given these are long-running migrations, it is statistically unlikely it will complete during in between\n#     this call and the time the process is killed\n#     2. Our Celery tasks are not essential for the functioning of PostHog, meaning losing a task is not the end of the world\n#     \"\"\"\n#     # Shortcut if we are still in starting state\n#     if migration.status == MigrationStatus.Starting:\n#         if halt_starting_migration(migration):\n#             return\n\n#     app.control.revoke(migration.celery_task_id, terminate=True)\n#     process_error(migration, error, rollback=rollback)\n\n\n# def rollback_migration(migration: AsyncMigration):\n#     from posthog.async_migrations.runner import attempt_migration_rollback\n\n#     attempt_migration_rollback(migration)\n\n\ndef complete_migration(migration: AsyncMigration, email: bool = True):\n    finished_at = now()\n\n    migration.refresh_from_db()\n\n    needs_update = migration.status != MigrationStatus.CompletedSuccessfully\n\n    if needs_update:\n        update_async_migration(\n            migration=migration,\n            status=MigrationStatus.CompletedSuccessfully,\n            finished_at=finished_at,\n            progress=100,\n        )\n"
  },
  {
    "path": "housewatch/async_migrations/runner.py",
    "content": "import structlog\nfrom sentry_sdk.api import capture_exception\n\n\nfrom housewatch.async_migrations.async_migration_utils import (\n    complete_migration,\n    execute_op,\n    mark_async_migration_as_running,\n    process_error,\n    update_async_migration,\n)\nfrom housewatch.models.async_migration import AsyncMigration, MigrationStatus\nfrom uuid import uuid4\n\n# from posthog.models.instance_setting import get_instance_setting\n# from posthog.models.utils import UUIDT\n# from posthog.version_requirement import ServiceVersionRequirement\n\n\"\"\"\nImportant to prevent us taking up too many celery workers and also to enable running migrations sequentially\n\"\"\"\nMAX_CONCURRENT_ASYNC_MIGRATIONS = 1\n\nlogger = structlog.get_logger(__name__)\n\n\ndef start_async_migration(migration: AsyncMigration, ignore_posthog_version=False) -> bool:\n\n    if migration.status not in [MigrationStatus.Starting, MigrationStatus.NotStarted]:\n        logger.error(f\"Initial check failed for async migration {migration.name}\")\n        return False\n\n    # ok, error = run_migration_precheck(migration)\n    # if not ok:\n    #     process_error(\n    #         migration, f\"Migration precheck failed with error:{error}\", status=MigrationStatus.FailedAtStartup\n    #     )\n    #     return False\n\n    # ok, error = run_migration_healthcheck(migration)\n    # if not ok:\n    #     process_error(\n    #         migration,\n    #         f\"Migration healthcheck failed with error:{error}\",\n    #         status=MigrationStatus.FailedAtStartup,\n    #     )\n    #     return False\n\n    if not mark_async_migration_as_running(migration):\n        # we don't want to touch the migration, i.e. don't process_error\n        logger.error(f\"Migration state has unexpectedly changed for async migration {migration.name}\")\n        return False\n\n    return run_async_migration_operations(migration)\n\n\ndef run_async_migration_operations(migration: AsyncMigration) -> bool:\n    while True:\n        run_next, success = run_async_migration_next_op(migration)\n        if not run_next:\n            return success\n\n\ndef run_async_migration_next_op(migration: AsyncMigration):\n    \"\"\"\n    Runs the next operation specified by the currently running migration\n    We run the next operation of the migration which needs attention\n\n    Returns (run_next, success)\n    Terminology:\n    - migration: The migration object as stored in the DB\n    - migration_definition: The actual migration class outlining the operations (e.g. async_migrations/examples/example.py)\n    \"\"\"\n\n    migration.refresh_from_db()\n\n    if migration.current_operation_index > len(migration.operations) - 1:\n        logger.info(\n            \"Marking async migration as complete\",\n            migration=migration.name,\n            current_operation_index=migration.current_operation_index,\n        )\n        complete_migration(migration)\n        return (False, True)\n\n    error = None\n    current_query_id = str(uuid4())\n\n    try:\n        logger.info(\n            \"Running async migration operation\",\n            migration=migration.name,\n            current_operation_index=migration.current_operation_index,\n        )\n        op = migration.operations[migration.current_operation_index]\n\n        execute_op(op, query_id=current_query_id)\n        update_async_migration(\n            migration=migration,\n            current_query_id=current_query_id,\n            current_operation_index=migration.current_operation_index + 1,\n        )\n\n    except Exception as e:\n        error = f\"Exception was thrown while running operation {migration.current_operation_index} : {str(e)}\"\n        logger.error(\n            \"Error running async migration operation\",\n            migration=migration.name,\n            current_operation_index=migration.current_operation_index,\n            error=e,\n        )\n        capture_exception(e)\n        process_error(migration, error)\n\n    if error:\n        return (False, False)\n\n    update_migration_progress(migration)\n    return (True, False)\n\n\n# def run_migration_healthcheck(migration: AsyncMigration):\n#     return get_async_migration_definition(migration.name).healthcheck()\n\n\n# def run_migration_precheck(migration: AsyncMigration):\n#     return get_async_migration_definition(migration.name).precheck()\n\n\ndef update_migration_progress(migration: AsyncMigration):\n    \"\"\"\n    We don't want to interrupt a migration if the progress check fails, hence try without handling exceptions\n    Progress is a nice-to-have bit of feedback about how the migration is doing, but not essential\n    \"\"\"\n\n    migration.refresh_from_db()\n    try:\n        update_async_migration(\n            migration=migration, progress=int((migration.current_operation_index / len(migration.operations)) * 100)\n        )\n    except Exception:\n        pass\n\n\ndef attempt_migration_rollback(migration: AsyncMigration):\n    \"\"\"\n    Cycle through the operations in reverse order starting from the last completed op and run\n    the specified rollback statements.\n    \"\"\"\n    migration.refresh_from_db()\n    ops = migration.rollback_operations\n    if ops is not None:\n        # if the migration was completed the index is set 1 after, normally we should try rollback for current op\n        current_index = min(migration.current_operation_index, len(ops) - 1)\n        for op_index in range(current_index, -1, -1):\n            try:\n                op = ops[op_index]\n                if not op:\n                    continue\n                execute_op(op, query_id=str(uuid4))\n            except Exception as e:\n                last_error = f\"At operation {op_index} rollback failed with error:{str(e)}\"\n                process_error(\n                    migration=migration,\n                    last_error=last_error,\n                    rollback=False,\n                    current_operation_index=op_index,\n                )\n\n                return\n\n    update_async_migration(\n        migration=migration, status=MigrationStatus.RolledBack, progress=0, current_operation_index=0\n    )\n"
  },
  {
    "path": "housewatch/celery.py",
    "content": "import os\nfrom datetime import datetime\n\nimport structlog\nfrom croniter import croniter\nfrom celery import Celery\nfrom django_structlog.celery.steps import DjangoStructLogInitStep\nfrom django.utils import timezone\n\nlogger = structlog.get_logger(__name__)\n\n# set the default Django settings module for the 'celery' program.\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"housewatch.settings\")\n\napp = Celery(\"housewatch\")\n\n# Using a string here means the worker doesn't have to serialize\n# the configuration object to child processes.\n# - namespace='CELERY' means all celery-related configuration keys\n#   should have a `CELERY_` prefix.\napp.config_from_object(\"django.conf:settings\", namespace=\"CELERY\")\n\n# Load task modules from all registered Django app configs.\napp.autodiscover_tasks()\n\napp.steps[\"worker\"].add(DjangoStructLogInitStep)\n\n\n@app.on_after_configure.connect\ndef setup_periodic_tasks(sender: Celery, **kwargs):\n    sender.add_periodic_task(60.0, schedule_backups.s(), name=\"schedule backups\")\n\n\n@app.task(track_started=True, ignore_result=False, max_retries=0)\ndef run_backup(backup_id: str, incremental: bool = False):\n    from housewatch.clickhouse import backups\n\n    logger.info(\"Running backup\", backup_id=backup_id, incremental=incremental)\n\n    backups.run_backup(backup_id, incremental=incremental)\n\n\n@app.task(track_started=True, ignore_result=False, max_retries=0)\ndef schedule_backups():\n    # Every interval (default 60 seconds), check if any backups need to be run\n    # We do this so that if we disable or change the schedule of a backup, it will be picked up within 60 seconds\n    # instead of waiting for the next deployment or restart of HouseWatch service\n    from housewatch.models.backup import ScheduledBackup\n\n    logger.info(\"Checking if scheduled backups need to be run\")\n    backups = ScheduledBackup.objects.filter(enabled=True)\n    now = timezone.now()\n    for backup in backups:\n        lrt = backup.last_run_time\n        if lrt is None:\n            lrt = backup.created_at\n        nr = croniter(backup.schedule, lrt).get_next(datetime)\n        if nr.tzinfo is None:\n            nr = timezone.make_aware(nr)\n\n        nir = None\n        if backup.incremental_schedule is not None:\n            lirt = backup.last_incremental_run_time\n            if lirt is None:\n                lirt = backup.created_at\n            nir = croniter(backup.incremental_schedule, lirt).get_next(datetime)\n            if nir.tzinfo is None:\n                nir = timezone.make_aware(nir)\n\n        logger.info(\"Checking backup\", backup_id=backup.id, next_run=nr, next_incremental_run=nir, now=now)\n        # The idea here is to first check if the base backup needs to be run, and if not, check if the incremental\n        # backup needs to be run. This is because incremental backups are only useful if the base backup has been run\n        # at least once.\n        if nr < now:\n            # check if base backup needs to be run and run it\n            run_backup.delay(backup.id)\n            backup.last_run_time = now\n            backup.save()\n        elif backup.incremental_schedule is not None and nir < now:\n            # check if incremental backup needs to be run and run it\n            run_backup.delay(backup.id, incremental=True)\n            backup.last_incremental_run_time = now\n            backup.save()\n\n\n@app.task(track_started=True, ignore_result=False, max_retries=0)\ndef run_async_migration(migration_name: str):\n    from housewatch.async_migrations.runner import start_async_migration\n    from housewatch.models.async_migration import AsyncMigration\n\n    migration = AsyncMigration.objects.get(name=migration_name)\n    start_async_migration(migration)\n"
  },
  {
    "path": "housewatch/clickhouse/__init__.py",
    "content": ""
  },
  {
    "path": "housewatch/clickhouse/backups.py",
    "content": "import structlog\nfrom collections import defaultdict\nfrom datetime import datetime\nfrom typing import Dict, Optional\nfrom uuid import uuid4\nfrom housewatch.clickhouse.client import run_query\nfrom housewatch.models.backup import ScheduledBackup, ScheduledBackupRun\nfrom housewatch.clickhouse.table import table_engine_full\nfrom housewatch.clickhouse.clusters import get_node_per_shard\n\nfrom django.conf import settings\nfrom django.utils import timezone\n\nfrom clickhouse_driver import Client\n\nlogger = structlog.get_logger(__name__)\n\n\ndef execute_backup(\n    query: str,\n    params: Dict[str, str | int] = {},\n    query_settings: Dict[str, str | int] = {},\n    query_id: Optional[str] = None,\n    substitute_params: bool = True,\n    cluster: Optional[str] = \"default\",\n    aws_key: Optional[str] = None,\n    aws_secret: Optional[str] = None,\n    base_backup: Optional[str] = None,\n    is_sharded: bool = False,\n):\n    query_settings = {}\n    \"\"\"\n    This function will execute a backup on each shard in a cluster\n    This is very similar to run_query_on_shards but it has very specific params\n    for backups - specifically around base_backup settings\n    \"\"\"\n    nodes = get_node_per_shard(cluster)\n    responses = []\n    for shard, node in nodes:\n        params[\"shard\"] = shard if is_sharded else \"noshard\"\n\n        query_settings[\"async\"] = \"true\"\n        if base_backup:\n            query_settings[\"base_backup\"] = f\"S3('{base_backup}/{params['shard']}', '{aws_key}', '{aws_secret}')\"\n\n        parametrized_query = query % (params or {}) if substitute_params else query\n        final_query = \"{query} SETTINGS {settings}\".format(\n            query=parametrized_query,\n            settings=\", \".join([f\"{k} = {v}\" for k, v in query_settings.items()]),\n        )\n\n        client = Client(\n            host=node[\"host_address\"],\n            database=settings.CLICKHOUSE_DATABASE,\n            user=settings.CLICKHOUSE_USER,\n            secure=settings.CLICKHOUSE_SECURE,\n            ca_certs=settings.CLICKHOUSE_CA,\n            verify=settings.CLICKHOUSE_VERIFY,\n            settings={\"max_result_rows\": \"2000\"},\n            send_receive_timeout=30,\n            password=settings.CLICKHOUSE_PASSWORD,\n        )\n        result = client.execute(final_query, with_column_types=True, query_id=query_id)\n        response = []\n        for res in result[0]:\n            item = {}\n            for index, key in enumerate(result[1]):\n                item[key[0]] = res[index]\n            response.append(item)\n        responses.append((shard, response))\n        if not is_sharded:\n            # if the table is not sharded then just run the backup on one node\n            return response\n    return response\n\n\ndef get_backups(cluster=None):\n    if cluster:\n        QUERY = \"\"\"SELECT id, name, status, error, start_time, end_time, num_files, formatReadableSize(total_size) total_size, num_entries, uncompressed_size, compressed_size, files_read, bytes_read FROM clusterAllReplicas(%(cluster)s, system.backups) ORDER BY start_time DESC\"\"\"\n    else:\n        QUERY = \"\"\"SELECT id, name, status, error, start_time, end_time, num_files, formatReadableSize(total_size) total_size, num_entries, uncompressed_size, compressed_size, files_read, bytes_read FROM system.backups ORDER BY start_time DESC\"\"\"\n    res = run_query(QUERY, {\"cluster\": cluster}, use_cache=False)\n    return res\n\n\ndef get_backup(backup, cluster=None):\n    if cluster:\n        QUERY = \"\"\"Select * FROM clusterAllReplicas(%(cluster)s, system.backups) WHERE id = '%(uuid)s' \"\"\"\n        return run_query(QUERY, {\"cluster\": cluster, \"uuid\": backup}, use_cache=False)\n    else:\n        QUERY = \"\"\"Select * FROM system.backups WHERE id = '%(uuid)s' \"\"\"\n        return run_query(QUERY, {\"uuid\": backup}, use_cache=False)\n\n\ndef create_table_backup(\n    database, table, bucket, path, aws_key=None, aws_secret=None, base_backup=None, is_sharded=False, cluster=\"default\"\n):\n    if aws_key is None or aws_secret is None:\n        aws_key = settings.AWS_ACCESS_KEY_ID\n        aws_secret = settings.AWS_SECRET_ACCESS_KEY\n\n    QUERY = \"\"\"BACKUP TABLE %(database)s.%(table)s\n    TO S3('https://%(bucket)s.s3.amazonaws.com/%(path)s/%(shard)s', '%(aws_key)s', '%(aws_secret)s')\"\"\"\n    return execute_backup(\n        QUERY,\n        {\n            \"database\": database,\n            \"table\": table,\n            \"bucket\": bucket,\n            \"path\": path,\n            \"aws_key\": aws_key,\n            \"aws_secret\": aws_secret,\n        },\n        cluster=cluster,\n        aws_key=aws_key,\n        aws_secret=aws_secret,\n        base_backup=base_backup,\n        is_sharded=is_sharded,\n    )\n\n\ndef create_database_backup(database, bucket, path, aws_key=None, aws_secret=None, base_backup=None, cluster=\"default\", is_sharded=False):\n    if aws_key is None or aws_secret is None:\n        aws_key = settings.AWS_ACCESS_KEY_ID\n        aws_secret = settings.AWS_SECRET_ACCESS_KEY\n\n    QUERY = \"\"\"BACKUP DATABASE %(database)s\n                TO S3('https://%(bucket)s.s3.amazonaws.com/%(path)s/%(shard)s', '%(aws_key)s', '%(aws_secret)s')\"\"\"\n    return execute_backup(\n        QUERY,\n        {\n            \"database\": database,\n            \"bucket\": bucket,\n            \"path\": path,\n            \"aws_key\": aws_key,\n            \"aws_secret\": aws_secret,\n        },\n        cluster=cluster,\n        aws_key=aws_key,\n        aws_secret=aws_secret,\n        base_backup=base_backup,\n        is_sharded=is_sharded,\n    )\n\n\ndef run_backup(backup_id, incremental=False):\n    backup = ScheduledBackup.objects.get(id=backup_id)\n    now = timezone.now()\n    path = backup.path + \"/\" + now.isoformat()\n    base_backup = None\n    S3_LOCATION = f\"https://{backup.bucket}.s3.amazonaws.com/{path}\"\n    if incremental:\n        if not backup.last_run or not backup.last_base_backup:\n            logger.info(\"Cannot run incremental backup without a base backup, running base backup\")\n            incremental = False\n        else:\n            base_backup = backup.last_base_backup\n    if backup.is_database_backup():\n        create_database_backup(\n            backup.database,\n            backup.bucket,\n            path,\n            aws_key=backup.aws_access_key_id,\n            aws_secret=backup.aws_secret_access_key,\n            cluster=backup.cluster,\n            base_backup=base_backup,\n            is_sharded=backup.is_sharded,\n        )\n    elif backup.is_table_backup():\n        create_table_backup(\n            backup.database,\n            backup.table,\n            backup.bucket,\n            path,\n            aws_key=backup.aws_access_key_id,\n            aws_secret=backup.aws_secret_access_key,\n            cluster=backup.cluster,\n            base_backup=base_backup,\n            is_sharded=backup.is_sharded,\n        )\n    uuid = str(uuid4())\n    br = ScheduledBackupRun.objects.create(\n        scheduled_backup=backup, id=uuid, started_at=now, is_incremental=incremental, base_backup=base_backup\n    )\n    br.save()\n    if incremental:\n        backup.last_incremental_run = br\n        backup.last_incremental_run_time = now\n    else:\n        backup.last_run = br\n        backup.last_run_time = now\n\n    backup.last_base_backup = S3_LOCATION\n    backup.save()\n    return\n\n\ndef restore_backup(backup):\n    pass\n"
  },
  {
    "path": "housewatch/clickhouse/client.py",
    "content": "import os\nfrom typing import Dict, Optional\nfrom clickhouse_pool import ChPool\nfrom clickhouse_driver import Client\nfrom housewatch.clickhouse.queries.sql import EXISTING_TABLES_SQL\nfrom housewatch.utils import str_to_bool\nfrom django.core.cache import cache\nfrom django.conf import settings\nimport hashlib\nimport json\n\n\npool = ChPool(\n    host=settings.CLICKHOUSE_HOST,\n    database=settings.CLICKHOUSE_DATABASE,\n    user=settings.CLICKHOUSE_USER,\n    secure=settings.CLICKHOUSE_SECURE,\n    ca_certs=settings.CLICKHOUSE_CA,\n    verify=settings.CLICKHOUSE_VERIFY,\n    settings={\"max_result_rows\": \"2000\"},\n    send_receive_timeout=30,\n    password=settings.CLICKHOUSE_PASSWORD,\n)\n\n\ndef run_query_on_shards(\n    query: str,\n    params: Dict[str, str | int] = {},\n    query_settings: Dict[str, str | int] = {},\n    query_id: Optional[str] = None,\n    substitute_params: bool = True,\n    cluster: Optional[str] = None,\n):\n    from housewatch.clickhouse.clusters import get_node_per_shard\n\n    nodes = get_node_per_shard(cluster)\n    responses = []\n    for shard, node in nodes:\n        params[\"shard\"] = shard\n        final_query = query % (params or {}) if substitute_params else query\n        client = Client(\n            host=node[\"host_address\"],\n            database=settings.CLICKHOUSE_DATABASE,\n            user=settings.CLICKHOUSE_USER,\n            secure=settings.CLICKHOUSE_SECURE,\n            ca_certs=settings.CLICKHOUSE_CA,\n            verify=settings.CLICKHOUSE_VERIFY,\n            settings={\"max_result_rows\": \"2000\"},\n            send_receive_timeout=30,\n            password=settings.CLICKHOUSE_PASSWORD,\n        )\n        result = client.execute(final_query, settings=query_settings, with_column_types=True, query_id=query_id)\n        response = []\n        for res in result[0]:\n            item = {}\n            for index, key in enumerate(result[1]):\n                item[key[0]] = res[index]\n\n            response.append(item)\n        responses.append((shard, response))\n    return response\n\n\ndef run_query(\n    query: str,\n    params: Dict[str, str | int] = {},\n    settings: Dict[str, str | int] = {},\n    query_id: Optional[str] = None,\n    use_cache: bool = True,  # defaulting to True for now for simplicity, but ideally we should default this to False\n    substitute_params: bool = True,\n    cluster: Optional[str] = None,\n):\n    final_query = query % (params or {}) if substitute_params else query\n    query_hash = \"\"\n\n    if use_cache:\n        query_hash = hashlib.sha256(final_query.encode(\"utf-8\")).hexdigest()\n        cached_result = cache.get(query_hash)\n        if cached_result:\n            return json.loads(cached_result)\n\n    with pool.get_client() as client:\n        result = client.execute(final_query, settings=settings, with_column_types=True, query_id=query_id)\n        response = []\n        for res in result[0]:\n            item = {}\n            for index, key in enumerate(result[1]):\n                item[key[0]] = res[index]\n\n            response.append(item)\n        if use_cache:\n            cache.set(query_hash, json.dumps(response, default=str), timeout=60 * 5)\n        return response\n\n\nexisting_system_tables = [row[\"name\"] for row in run_query(EXISTING_TABLES_SQL, use_cache=False)]\n"
  },
  {
    "path": "housewatch/clickhouse/clusters.py",
    "content": "import random\nfrom collections import defaultdict\nfrom housewatch.clickhouse.client import run_query\n\nfrom housewatch.models.preferred_replica import PreferredReplica\n\n\ndef get_clusters():\n    QUERY = \"\"\"Select cluster, shard_num, shard_weight, replica_num, host_name, host_address, port, is_local, user, default_database, errors_count, slowdowns_count, estimated_recovery_time FROM system.clusters\"\"\"\n    res = run_query(QUERY)\n    clusters = defaultdict(list)\n    for c_node in res:\n        clusters[c_node[\"cluster\"]].append(c_node)\n    accumulator = []\n    for cluster, nodes in clusters.items():\n        accumulator.append({\"cluster\": cluster, \"nodes\": nodes})\n    return accumulator\n\n\ndef get_cluster(cluster):\n    QUERY = \"\"\"Select cluster, shard_num, shard_weight, replica_num, host_name, host_address, port, is_local, user, default_database, errors_count, slowdowns_count, estimated_recovery_time FROM system.clusters WHERE cluster = '%(cluster_name)s' \"\"\"\n    return run_query(QUERY, {\"cluster_name\": cluster})\n\n\ndef get_shards(cluster):\n    cluster = get_cluster(cluster)\n    nodes = defaultdict(list)\n    for node in cluster:\n        nodes[node[\"shard_num\"]].append(node)\n    return nodes\n\n\ndef get_node_per_shard(cluster):\n    # We want to return a node per shard, but if we have preferred replicas we should use those\n\n    shards = get_shards(cluster)\n    nodes = []\n\n    preferred = PreferredReplica.objects.filter(cluster=cluster).values_list(\"replica\", flat=True)\n    for shard, n in shards.items():\n        preferred_replica_found = False\n        # shuffle the nodes so we don't always pick the first preferred one\n        random.shuffle(n)\n        for node in n:\n            if node[\"host_name\"] in preferred:\n                nodes.append((shard, node))\n                preferred_replica_found = True\n                break\n        if not preferred_replica_found:\n            nodes.append((shard, random.choice(n)))\n    random.shuffle(nodes)\n    return nodes\n"
  },
  {
    "path": "housewatch/clickhouse/queries/__init__.py",
    "content": ""
  },
  {
    "path": "housewatch/clickhouse/queries/sql.py",
    "content": "import os\n\nch_cluster = os.getenv(\"CLICKHOUSE_CLUSTER\", None)\n\nQUERY_LOG_SYSTEM_TABLE = f\"clusterAllReplicas('{ch_cluster}', system.query_log)\" if ch_cluster else \"system.query_log\"\nTEXT_LOG_SYSTEM_TABLE = f\"clusterAllReplicas('{ch_cluster}', system.text_log)\" if ch_cluster else \"system.text\"\nERRORS_SYSTEM_TABLE = f\"clusterAllReplicas('{ch_cluster}', system.errors)\" if ch_cluster else \"system.errors\"\nDISKS_SYSTEM_TABLE = f\"clusterAllReplicas('{ch_cluster}', system.disks)\" if ch_cluster else \"system.disks\"\n\n\n# TODO: Add enum mapping dict for query `type`\nSLOW_QUERIES_SQL = f\"\"\"\nSELECT\n    normalizeQuery(query) AS normalized_query,\n    avg(query_duration_ms) AS avg_duration,\n    avg(result_rows),\n    count(1)/date_diff('minute', %(date_from)s, now()) AS calls_per_minute,\n    count(1),\n    formatReadableSize(sum(read_bytes)) AS total_read_bytes,\n    (sum(read_bytes)/(sum(sum(read_bytes)) over ()))*100 AS percentage_iops,\n    (sum(query_duration_ms)/(sum(sum(query_duration_ms)) over ()))*100 AS percentage_runtime,\n    toString(normalized_query_hash) AS normalized_query_hash\nFROM {QUERY_LOG_SYSTEM_TABLE}\nWHERE is_initial_query AND event_time > %(date_from)s AND type = 2\nGROUP BY normalized_query_hash, normalizeQuery(query)\nORDER BY sum(read_bytes) DESC\nLIMIT %(limit)s\n\"\"\"\n\n\nQUERY_LOAD_SQL = f\"\"\"\nSELECT toStartOfDay(event_time) AS day, %(math_func)s(%(load_metric)s) AS %(column_alias)s\nFROM {QUERY_LOG_SYSTEM_TABLE}\nWHERE\n    event_time >= toDateTime(%(date_from)s)\n    AND event_time <= toDateTime(%(date_to)s)\nGROUP BY day\nORDER BY day\n\"\"\"\n\nERRORS_SQL = f\"\"\"\nSELECT name, count() count, max(last_error_time) max_last_error_time\nFROM {ERRORS_SYSTEM_TABLE}\nWHERE last_error_time > %(date_from)s\nGROUP BY name\nORDER BY count DESC\n\"\"\"\n\nTABLES_SQL = \"\"\"\nSELECT\n    name,\n    formatReadableSize(total_bytes) AS readable_bytes,\n    total_bytes,\n    total_rows,\n    engine,\n    partition_key\nFROM system.tables ORDER BY total_bytes DESC\n\"\"\"\n\nSCHEMA_SQL = \"\"\"\nSELECT\n    table,\n    name AS column,\n    type,\n    data_compressed_bytes AS compressed,\n    formatReadableSize(data_compressed_bytes) AS compressed_readable,\n    formatReadableSize(data_uncompressed_bytes) AS uncompressed\nFROM system.columns\nWHERE table = '%(table)s'\nORDER BY data_compressed_bytes DESC\nLIMIT 100\n\"\"\"\n\nPARTS_SQL = \"\"\"\nSELECT name AS part, data_compressed_bytes AS compressed, formatReadableSize(data_compressed_bytes) AS compressed_readable, formatReadableSize(data_uncompressed_bytes) AS uncompressed\nFROM system.parts\nWHERE table = '%(table)s'\nORDER BY data_compressed_bytes DESC\nLIMIT 100\n\"\"\"\n\n\nGET_QUERY_BY_NORMALIZED_HASH_SQL = f\"\"\"\nSELECT normalizeQuery(query) AS normalized_query, groupUniqArray(10)(query) AS example_queries FROM\n{QUERY_LOG_SYSTEM_TABLE}\nWHERE normalized_query_hash = %(normalized_query_hash)s\nGROUP BY normalized_query\nlimit 1\n\"\"\"\n\nEXPLAIN_QUERY = \"\"\"\nEXPLAIN header=1, indexes=1\n%(query)s\n\"\"\"\n\nQUERY_EXECUTION_COUNT_SQL = f\"\"\"\nSELECT day_start, sum(total) AS total FROM (\n    SELECT\n        0 AS total,\n        toStartOfDay(now() - toIntervalDay(number)) AS day_start\n    FROM numbers(dateDiff('day', toStartOfDay(now()  - INTERVAL %(days)s day), now()))\n    UNION ALL\n    SELECT\n        count(*) AS total,\n        toStartOfDay(query_start_time) AS day_start\n    FROM\n        {QUERY_LOG_SYSTEM_TABLE}\n    WHERE\n        query_start_time > now() - INTERVAL %(days)s day AND type = 2 AND is_initial_query %(conditions)s\n    GROUP BY day_start\n)\nGROUP BY day_start\nORDER BY day_start asc\n\"\"\"\n\nQUERY_MEMORY_USAGE_SQL = f\"\"\"\nSELECT day_start, sum(total) AS total FROM (\n    SELECT\n        0 AS total,\n        toStartOfDay(now() - toIntervalDay(number)) AS day_start\n    FROM numbers(dateDiff('day', toStartOfDay(now()  - INTERVAL %(days)s day), now()))\n    UNION ALL\n\n    SELECT\n        sum(memory_usage) AS total,\n        toStartOfDay(query_start_time) AS day_start\n    FROM\n        {QUERY_LOG_SYSTEM_TABLE}\n    WHERE\n        event_time > now() - INTERVAL 12 day AND type = 2 AND is_initial_query %(conditions)s\n    GROUP BY day_start\n)\nGROUP BY day_start\nORDER BY day_start ASC\n\"\"\"\n\nQUERY_CPU_USAGE_SQL = f\"\"\"\nSELECT day_start, sum(total) AS total FROM (\n    SELECT\n        0 AS total,\n        toStartOfDay(now() - toIntervalDay(number)) AS day_start\n    FROM numbers(dateDiff('day', toStartOfDay(now()  - INTERVAL %(days)s day), now()))\n    UNION ALL\n    SELECT\n        sum(ProfileEvents['OSCPUVirtualTimeMicroseconds']) AS total,\n        toStartOfDay(query_start_time) AS day_start\n    FROM\n        {QUERY_LOG_SYSTEM_TABLE}\n    WHERE\n        event_time > now() - INTERVAL 12 day AND type = 2 AND is_initial_query %(conditions)s\n    GROUP BY day_start\n)\nGROUP BY day_start\nORDER BY day_start ASC\n\"\"\"\n\nQUERY_READ_BYTES_SQL = f\"\"\"\nSELECT day_start, sum(total) AS total FROM (\n    SELECT\n        0 AS total,\n        toStartOfDay(now() - toIntervalDay(number)) AS day_start\n    FROM numbers(dateDiff('day', toStartOfDay(now()  - INTERVAL %(days)s day), now()))\n    UNION ALL\n\n    SELECT\n        sum(read_bytes) AS total,\n        toStartOfDay(query_start_time) AS day_start\n    FROM\n        {QUERY_LOG_SYSTEM_TABLE}\n    WHERE\n        event_time > now() - INTERVAL 12 day AND type = 2 AND is_initial_query %(conditions)s\n    GROUP BY day_start\n)\nGROUP BY day_start\nORDER BY day_start ASC\n\"\"\"\n\nRUNNING_QUERIES_SQL = \"\"\"\nSELECT\n    query,\n    user,\n    elapsed,\n    read_rows,\n    formatReadableQuantity(read_rows) AS read_rows_readable,\n    total_rows_approx,\n    formatReadableQuantity(total_rows_approx) AS total_rows_approx_readable,\n    formatReadableSize(memory_usage) AS memory_usage,\n    query_id\nFROM system.processes\nWHERE Settings['log_comment'] != 'running_queries_lookup'\nORDER BY elapsed DESC\nSETTINGS log_comment = 'running_queries_lookup'\n\"\"\"\n\nKILL_QUERY_SQL = \"\"\"\nKILL QUERY WHERE query_id = '%(query_id)s'\n\"\"\"\n\nNODE_STORAGE_SQL = f\"\"\"\nSELECT\n    hostName() node,\n    sum(total_space) space_used,\n    sum(free_space) free_space,\n    (space_used + free_space) total_space_available,\n    formatReadableSize(total_space_available) readable_total_space_available,\n    formatReadableSize(space_used) readable_space_used,\n    formatReadableSize(free_space) readable_free_space\nFROM {DISKS_SYSTEM_TABLE}\nWHERE type = 'local'\nGROUP BY node\nORDER BY node\n\"\"\"\n\nNODE_DATA_TRANSFER_ACROSS_SHARDS_SQL = f\"\"\"\nSELECT hostName() node, sum(read_bytes) total_bytes_transferred, formatReadableSize(total_bytes_transferred) AS readable_bytes_transferred\nFROM {QUERY_LOG_SYSTEM_TABLE}\nWHERE is_initial_query != 0 AND type = 2\nGROUP BY node\nORDER BY node\n\"\"\"\n\nLOGS_SQL = f\"\"\"\nSELECT event_time, toString(level) level, hostName() hostname, message\nFROM {TEXT_LOG_SYSTEM_TABLE}\nWHERE message ILIKE '%(message)s'\nORDER BY event_time DESC\nLIMIT 100\n\"\"\"\n\nEXISTING_TABLES_SQL = \"\"\"\nSELECT name\nFROM system.tables\nWHERE database = 'system'\n\"\"\"\n\nLOGS_FREQUENCY_SQL = f\"\"\"\nSELECT hour, sum(total) AS total FROM (\n    SELECT\n        toStartOfHour(now() - toIntervalHour(number)) AS hour,\n        0 AS total\n    FROM numbers(dateDiff('hour', toStartOfHour(now()  - INTERVAL 3 day), now()))\n    GROUP BY hour\n    UNION ALL\n    SELECT toStartOfHour(event_time) hour, count() total\n    FROM {TEXT_LOG_SYSTEM_TABLE}\n    WHERE event_time > now() - INTERVAL 3 day AND message ILIKE '%(message)s'\n    GROUP BY hour\n)\nGROUP BY hour\nORDER BY hour\n\"\"\"\n\nBENCHMARKING_SQL = f\"\"\"\nSELECT\n    if(log_comment = '%(query1_tag)s', 'Control', 'Test') AS query_version,\n    sumIf(query_duration_ms, is_initial_query) AS duration_ms,\n    sumIf(memory_usage, is_initial_query) AS memory_usage,\n    sumIf(ProfileEvents['OSCPUVirtualTimeMicroseconds'], is_initial_query) AS cpu,\n    sumIf(read_bytes, is_initial_query) AS read_bytes,\n    sumIf(read_rows, NOT is_initial_query) AS read_bytes_from_other_shards,\n    sumIf(ProfileEvents['NetworkReceiveBytes'], is_initial_query) AS network_receive_bytes\nFROM {QUERY_LOG_SYSTEM_TABLE}\nWHERE\n    type = 2\n    AND event_time > now() - INTERVAL 10 MINUTE\n    AND (log_comment = '%(query1_tag)s' OR log_comment = '%(query2_tag)s')\nGROUP BY query_version\nORDER BY query_version\n\"\"\"\n\nAVAILABLE_TABLES_SQL = \"\"\"\nSELECT database, table\nFROM system.tables\nWHERE database NOT ILIKE 'information_schema'\n\"\"\"\n\nTABLE_SCHEMAS_SQL = \"\"\"\nSELECT database, table, create_table_query\nFROM system.tables\nWHERE %(conditions)s\n\"\"\"\n"
  },
  {
    "path": "housewatch/clickhouse/table.py",
    "content": "from housewatch.clickhouse.client import run_query\n\n\ndef is_replicated_table(database, table):\n    QUERY = \"\"\"SELECT engine FROM system.tables WHERE database = '%(database)s' AND name = '%(table)s'\"\"\"\n    return \"replicated\" in run_query(QUERY, {\"database\": database, \"table\": table})[0][\"engine\"].lower()\n\n\ndef table_engine_full(database, table):\n    QUERY = \"\"\"SELECT engine_full FROM system.tables WHERE database = '%(database)s' AND name = '%(table)s'\"\"\"\n    return run_query(QUERY, {\"database\": database, \"table\": table})[0][\"engine_full\"]\n\n\ndef parse_engine(engine_full):\n    engine = engine_full.split(\"(\")[0].strip()\n    params = engine_full.split(\"(\")[1].split(\")\")[0].split(\",\")\n    return engine, params\n\n\ndef is_sharded_table(database, table):\n    return \"sharded\" in table_engine_full(database, table).lower()\n"
  },
  {
    "path": "housewatch/gunicorn.conf.py",
    "content": "import logging\n\nfrom gunicorn import glogging\n\n\nclass CustomGunicornLogger(glogging.Logger):\n    def setup(self, cfg):\n        super().setup(cfg)\n\n        # Add filters to Gunicorn logger\n        logger = logging.getLogger(\"gunicorn.access\")\n        logger.addFilter(HealthCheckFilter())\n\n\nclass HealthCheckFilter(logging.Filter):\n    def filter(self, record):\n        return \"GET /healthz\" not in record.getMessage()\n\n\naccesslog = \"-\"\nlogger_class = CustomGunicornLogger\nlog_level = \"info\"\n\nbind = \"0.0.0.0:8100\"\nworkers = 2\n"
  },
  {
    "path": "housewatch/migrations/0001_initial.py",
    "content": "# Generated by Django 4.1.1 on 2023-03-29 01:06\n\nfrom django.db import migrations, models\nimport django.utils.timezone\n\n\nclass Migration(migrations.Migration):\n\n    initial = True\n\n    dependencies = []\n\n    operations = [\n        migrations.CreateModel(\n            name=\"Instance\",\n            fields=[\n                (\"id\", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name=\"ID\")),\n                (\"created_at\", models.DateTimeField(default=django.utils.timezone.now)),\n                (\"username\", models.CharField(max_length=200)),\n                (\"password\", models.CharField(max_length=200)),\n                (\"host\", models.CharField(max_length=200)),\n                (\"port\", models.IntegerField()),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "housewatch/migrations/0002_asyncmigration_asyncmigration_unique name.py",
    "content": "# Generated by Django 4.1.1 on 2023-03-29 16:26\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"housewatch\", \"0001_initial\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"AsyncMigration\",\n            fields=[\n                (\"id\", models.BigAutoField(primary_key=True, serialize=False)),\n                (\"name\", models.CharField(max_length=50)),\n                (\"description\", models.CharField(blank=True, max_length=400, null=True)),\n                (\"progress\", models.PositiveSmallIntegerField(default=0)),\n                (\"status\", models.PositiveSmallIntegerField(default=0)),\n                (\"current_operation_index\", models.PositiveSmallIntegerField(default=0)),\n                (\"current_query_id\", models.CharField(default=\"\", max_length=100)),\n                (\"task_id\", models.CharField(blank=True, default=\"\", max_length=100, null=True)),\n                (\"started_at\", models.DateTimeField(blank=True, null=True)),\n            ],\n        ),\n        migrations.AddConstraint(\n            model_name=\"asyncmigration\",\n            constraint=models.UniqueConstraint(fields=(\"name\",), name=\"unique name\"),\n        ),\n    ]\n"
  },
  {
    "path": "housewatch/migrations/0003_asyncmigration_operations_and_more.py",
    "content": "# Generated by Django 4.1.1 on 2023-03-29 17:18\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"housewatch\", \"0002_asyncmigration_asyncmigration_unique name\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"asyncmigration\",\n            name=\"operations\",\n            field=models.JSONField(default=[]),\n            preserve_default=False,\n        ),\n        migrations.AddField(\n            model_name=\"asyncmigration\",\n            name=\"rollback_operations\",\n            field=models.JSONField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "housewatch/migrations/0004_asyncmigration_last_error.py",
    "content": "# Generated by Django 4.1.1 on 2023-03-29 17:47\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"housewatch\", \"0003_asyncmigration_operations_and_more\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"asyncmigration\",\n            name=\"last_error\",\n            field=models.CharField(blank=True, default=\"\", max_length=800, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "housewatch/migrations/0005_asyncmigration_finished_at.py",
    "content": "# Generated by Django 4.1.1 on 2023-03-29 20:53\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"housewatch\", \"0004_asyncmigration_last_error\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"asyncmigration\",\n            name=\"finished_at\",\n            field=models.DateTimeField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "housewatch/migrations/0006_savedquery.py",
    "content": "# Generated by Django 4.1.1 on 2023-05-25 17:44\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"housewatch\", \"0005_asyncmigration_finished_at\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"SavedQuery\",\n            fields=[\n                (\"id\", models.BigAutoField(primary_key=True, serialize=False)),\n                (\"name\", models.CharField(max_length=200)),\n                (\"query\", models.CharField(max_length=2000)),\n                (\"created_at\", models.DateTimeField(auto_now=True)),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "housewatch/migrations/0007_scheduledbackup_scheduledbackuprun.py",
    "content": "# Generated by Django 4.1.1 on 2023-08-17 01:06\n\nfrom django.db import migrations, models\nimport django.db.models.deletion\nimport housewatch.utils.encrypted_fields.fields\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('housewatch', '0006_savedquery'),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='ScheduledBackup',\n            fields=[\n                ('id', models.UUIDField(primary_key=True, serialize=False)),\n                ('schedule', models.CharField(max_length=255)),\n                ('table', models.CharField(max_length=255, null=True)),\n                ('database', models.CharField(max_length=255)),\n                ('bucket', models.CharField(max_length=255)),\n                ('path', models.CharField(max_length=255)),\n                ('aws_access_key_id', housewatch.utils.encrypted_fields.fields.EncryptedCharField(max_length=255, null=True)),\n                ('aws_secret_access_key', housewatch.utils.encrypted_fields.fields.EncryptedCharField(max_length=255, null=True)),\n                ('aws_region', housewatch.utils.encrypted_fields.fields.EncryptedCharField(max_length=255, null=True)),\n                ('aws_endpoint_url', housewatch.utils.encrypted_fields.fields.EncryptedCharField(max_length=255, null=True)),\n            ],\n        ),\n        migrations.CreateModel(\n            name='ScheduledBackupRun',\n            fields=[\n                ('id', models.UUIDField(primary_key=True, serialize=False)),\n                ('started_at', models.DateTimeField(auto_now_add=True)),\n                ('finished_at', models.DateTimeField(null=True)),\n                ('success', models.BooleanField(default=False)),\n                ('error', models.TextField(null=True)),\n                ('scheduled_backup', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='housewatch.scheduledbackup')),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "housewatch/migrations/0008_remove_scheduledbackup_aws_endpoint_url_and_more.py",
    "content": "# Generated by Django 4.1.1 on 2023-08-19 00:06\n\nfrom django.db import migrations, models\nimport django.db.models.deletion\nimport django.utils.timezone\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('housewatch', '0007_scheduledbackup_scheduledbackuprun'),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name='scheduledbackup',\n            name='aws_endpoint_url',\n        ),\n        migrations.RemoveField(\n            model_name='scheduledbackup',\n            name='aws_region',\n        ),\n        migrations.AddField(\n            model_name='scheduledbackup',\n            name='created_at',\n            field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),\n            preserve_default=False,\n        ),\n        migrations.AddField(\n            model_name='scheduledbackup',\n            name='enabled',\n            field=models.BooleanField(default=False),\n        ),\n        migrations.AddField(\n            model_name='scheduledbackup',\n            name='last_run',\n            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='housewatch.scheduledbackuprun'),\n        ),\n        migrations.AddField(\n            model_name='scheduledbackup',\n            name='last_run_time',\n            field=models.DateTimeField(null=True),\n        ),\n        migrations.AddField(\n            model_name='scheduledbackuprun',\n            name='created_at',\n            field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),\n            preserve_default=False,\n        ),\n    ]\n"
  },
  {
    "path": "housewatch/migrations/0009_scheduledbackup_cluster_alter_scheduledbackup_id.py",
    "content": "# Generated by Django 4.1.1 on 2023-08-24 01:33\n\nfrom django.db import migrations, models\nimport uuid\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('housewatch', '0008_remove_scheduledbackup_aws_endpoint_url_and_more'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='scheduledbackup',\n            name='cluster',\n            field=models.CharField(max_length=255, null=True),\n        ),\n        migrations.AlterField(\n            model_name='scheduledbackup',\n            name='id',\n            field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),\n        ),\n    ]\n"
  },
  {
    "path": "housewatch/migrations/0010_scheduledbackup_incremental_schedule_and_more.py",
    "content": "# Generated by Django 4.1.1 on 2023-09-12 02:19\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('housewatch', '0009_scheduledbackup_cluster_alter_scheduledbackup_id'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='scheduledbackup',\n            name='incremental_schedule',\n            field=models.CharField(max_length=255, null=True),\n        ),\n        migrations.AddField(\n            model_name='scheduledbackup',\n            name='last_base_backup',\n            field=models.CharField(max_length=255, null=True),\n        ),\n        migrations.AddField(\n            model_name='scheduledbackup',\n            name='last_incremental_run_time',\n            field=models.DateTimeField(null=True),\n        ),\n        migrations.AddField(\n            model_name='scheduledbackuprun',\n            name='base_backup',\n            field=models.CharField(max_length=255, null=True),\n        ),\n        migrations.AddField(\n            model_name='scheduledbackuprun',\n            name='is_incremental',\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "housewatch/migrations/0011_scheduledbackup_is_sharded_and_more.py",
    "content": "# Generated by Django 4.1.1 on 2024-01-31 06:36\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('housewatch', '0010_scheduledbackup_incremental_schedule_and_more'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='scheduledbackup',\n            name='is_sharded',\n            field=models.BooleanField(default=False),\n        ),\n        migrations.AlterField(\n            model_name='scheduledbackup',\n            name='table',\n            field=models.CharField(blank=True, max_length=255, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "housewatch/migrations/0012_preferredreplica.py",
    "content": "# Generated by Django 4.1.1 on 2024-05-29 16:34\n\nfrom django.db import migrations, models\nimport uuid\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('housewatch', '0011_scheduledbackup_is_sharded_and_more'),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='PreferredReplica',\n            fields=[\n                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),\n                ('created_at', models.DateTimeField(auto_now_add=True)),\n                ('cluster', models.CharField(max_length=255)),\n                ('replica', models.CharField(max_length=255)),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "housewatch/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "housewatch/models/__init__.py",
    "content": "from .instance import Instance\nfrom .backup import ScheduledBackup, ScheduledBackupRun\n\n__all__ = [\"Instance\"]\n"
  },
  {
    "path": "housewatch/models/async_migration.py",
    "content": "from django.db import models\n\n\nclass MigrationStatus:\n    NotStarted = 0\n    Running = 1\n    CompletedSuccessfully = 2\n    Errored = 3\n    RolledBack = 4\n    Starting = 5  # only relevant for the UI\n    FailedAtStartup = 6\n\n\n# Async migrations are now called \"Operations\" in the frontend\nclass AsyncMigration(models.Model):\n    class Meta:\n        constraints = [models.UniqueConstraint(fields=[\"name\"], name=\"unique name\")]\n\n    id: models.BigAutoField = models.BigAutoField(primary_key=True)\n    name: models.CharField = models.CharField(max_length=50, null=False, blank=False)\n    description: models.CharField = models.CharField(max_length=400, null=True, blank=True)\n    progress: models.PositiveSmallIntegerField = models.PositiveSmallIntegerField(null=False, blank=False, default=0)\n    status: models.PositiveSmallIntegerField = models.PositiveSmallIntegerField(\n        null=False, blank=False, default=MigrationStatus.NotStarted\n    )\n\n    current_operation_index: models.PositiveSmallIntegerField = models.PositiveSmallIntegerField(\n        null=False, blank=False, default=0\n    )\n\n    current_query_id: models.CharField = models.CharField(max_length=100, null=False, blank=False, default=\"\")\n    task_id: models.CharField = models.CharField(max_length=100, null=True, blank=True, default=\"\")\n\n    started_at: models.DateTimeField = models.DateTimeField(null=True, blank=True)\n    finished_at: models.DateTimeField = models.DateTimeField(null=True, blank=True)\n\n    last_error: models.CharField = models.CharField(max_length=800, null=True, blank=True, default=\"\")\n\n    # list of SQL operations\n    operations: models.JSONField = models.JSONField(null=False, blank=False)\n    rollback_operations: models.JSONField = models.JSONField(null=True, blank=True)\n\n    # TODO: Add precheck, healthcheck, postcheck\n"
  },
  {
    "path": "housewatch/models/backup.py",
    "content": "import uuid\nfrom croniter import croniter\nfrom django.db import models\n\nfrom housewatch.utils.encrypted_fields.fields import EncryptedCharField\n\n\nclass ScheduledBackup(models.Model):\n    id: models.UUIDField = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)\n    created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True)\n    enabled: models.BooleanField = models.BooleanField(default=False)\n    last_run_time: models.DateTimeField = models.DateTimeField(null=True)\n    last_incremental_run_time: models.DateTimeField = models.DateTimeField(null=True)\n    last_base_backup: models.CharField = models.CharField(max_length=255, null=True)\n    last_run: models.ForeignKey = models.ForeignKey(\"ScheduledBackupRun\", on_delete=models.SET_NULL, null=True)\n\n    # This will be a CRON expression for the job\n    schedule: models.CharField = models.CharField(max_length=255)\n    incremental_schedule: models.CharField = models.CharField(max_length=255, null=True)\n    table: models.CharField = models.CharField(max_length=255, null=True, blank=True)\n    database: models.CharField = models.CharField(max_length=255)\n    is_sharded: models.BooleanField = models.BooleanField(default=False)\n    cluster: models.CharField = models.CharField(max_length=255, null=True)\n    bucket: models.CharField = models.CharField(max_length=255)\n    path: models.CharField = models.CharField(max_length=255)\n    # if set these will override the defaults from settings\n    # raw keys will not be stored here but will obfuscated\n    aws_access_key_id: models.CharField = EncryptedCharField(max_length=255, null=True)\n    aws_secret_access_key: models.CharField = EncryptedCharField(max_length=255, null=True)\n\n    def cron_schedule(self):\n        return self.schedule.split(\" \")\n\n    def minute(self):\n        return self.schedule.split(\" \")[0]\n\n    def hour(self):\n        return self.schedule.split(\" \")[1]\n\n    def day_of_week(self):\n        return self.schedule.split(\" \")[4]\n\n    def day_of_month(self):\n        return self.schedule.split(\" \")[2]\n\n    def month_of_year(self):\n        return self.schedule.split(\" \")[3]\n\n    def is_database_backup(self):\n        return not self.table\n\n    def is_table_backup(self):\n        return self.table is not None\n\n    def save(self, *args, **kwargs):\n        if not croniter.is_valid(self.schedule):\n            raise ValueError(\"Invalid CRON expression\")\n        if self.incremental_schedule and not croniter.is_valid(self.incremental_schedule):\n            raise ValueError(\"Invalid CRON expression\")\n        super().save(*args, **kwargs)\n\n\nclass ScheduledBackupRun(models.Model):\n    id: models.UUIDField = models.UUIDField(primary_key=True)\n    created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True)\n    base_backup: models.CharField = models.CharField(max_length=255, null=True)\n    is_incremental: models.BooleanField = models.BooleanField(default=False)\n    scheduled_backup: models.ForeignKey = models.ForeignKey(ScheduledBackup, on_delete=models.CASCADE)\n    started_at: models.DateTimeField = models.DateTimeField(auto_now_add=True)\n    finished_at: models.DateTimeField = models.DateTimeField(null=True)\n    success: models.BooleanField = models.BooleanField(default=False)\n    error: models.TextField = models.TextField(null=True)\n"
  },
  {
    "path": "housewatch/models/instance.py",
    "content": "from django.db import models\nfrom django.utils import timezone\n\n\nclass Instance(models.Model):\n    created_at: models.DateTimeField = models.DateTimeField(default=timezone.now)\n    username: models.CharField = models.CharField(max_length=200)\n    password: models.CharField = models.CharField(max_length=200)\n    host: models.CharField = models.CharField(max_length=200)\n    port: models.IntegerField = models.IntegerField()\n"
  },
  {
    "path": "housewatch/models/preferred_replica.py",
    "content": "import uuid\nfrom django.db import models\n\n\nclass PreferredReplica(models.Model):\n    id: models.UUIDField = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)\n    created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True)\n    cluster: models.CharField = models.CharField(max_length=255)\n    replica: models.CharField = models.CharField(max_length=255)\n"
  },
  {
    "path": "housewatch/models/saved_queries.py",
    "content": "from django.db import models\n\n\nclass SavedQuery(models.Model):\n    id: models.BigAutoField = models.BigAutoField(primary_key=True)\n    name: models.CharField = models.CharField(max_length=200)\n    query: models.CharField = models.CharField(max_length=2000)\n    created_at: models.DateTimeField = models.DateTimeField(auto_now=True)\n"
  },
  {
    "path": "housewatch/settings/__init__.py",
    "content": "\"\"\"\nDjango settings for housewatch project.\n\nGenerated by 'django-admin startproject' using Django 4.1.1.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/4.1/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/4.1/ref/settings/\n\"\"\"\n\nimport os\nimport sys\nfrom datetime import timedelta\nfrom pathlib import Path\nfrom typing import Any, Callable, Optional\nimport dj_database_url\n\nfrom django.core.exceptions import ImproperlyConfigured\nfrom kombu import Exchange, Queue\nimport sentry_sdk\nfrom sentry_sdk.integrations.django import DjangoIntegration\n\nfrom housewatch.utils import str_to_bool\n\n\nsentry_sdk.init(\n    dsn=\"https://8874d21e05d62df688505df70c9f053d@o1015702.ingest.us.sentry.io/4507393944846336\",\n    integrations=[DjangoIntegration()],\n    # If you wish to associate users to errors (assuming you are using\n    # django.contrib.auth) you may enable sending PII data.\n    send_default_pii=True,\n    # Set traces_sample_rate to 1.0 to capture 100%\n    # of transactions for performance monitoring.\n    # We recommend adjusting this value in production.\n    traces_sample_rate=0.0,\n    # Set profiles_sample_rate to 1.0 to profile 100%\n    # of sampled transactions.\n    # We recommend adjusting this value in production.\n    profiles_sample_rate=0.0,\n)\n\n# TODO: Figure out why things dont work on cloud without debug\nDEBUG = os.getenv(\"DEBUG\", \"false\").lower() in [\"true\", \"1\"]\n\nif \"mypy\" in sys.modules:\n    DEBUG = True\n\n\ndef get_from_env(key: str, default: Any = None, *, optional: bool = False, type_cast: Optional[Callable] = None) -> Any:\n    value = os.getenv(key)\n    if value is None or value == \"\":\n        if optional:\n            return None\n        if default is not None:\n            return default\n        else:\n            if not DEBUG:\n                raise ImproperlyConfigured(f'The environment variable \"{key}\" is required to run HouseWatch!')\n    if type_cast is not None:\n        return type_cast(value)\n    return value\n\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nTEST = False\n\nif \"pytest\" in sys.modules or \"mypy\" in sys.modules:\n    TEST = True\n\n# this is ok for now as we don't have auth, crypto, or anything that uses the secret key\nSECRET_KEY = get_from_env(\"SECRET_KEY\", \"not-so-secret\")\n\nif DEBUG:\n    print(\"WARNING: Running debug mode!\")\n\nis_development = DEBUG and not TEST\n\n\nSECURE_SSL_REDIRECT = False\nif not DEBUG and not TEST:\n    USE_X_FORWARDED_HOST = True\n    USE_X_FORWARDED_PORT = True\n    SECURE_PROXY_SSL_HEADER = (\"HTTP_X_FORWARDED_PROTO\", \"https\")\n\n\nCSRF_TRUSTED_ORIGINS = [\"https://*.posthog.dev\", \"https://*.posthog.com\"]\n\n\nAPPEND_SLASH = False\n\n\n# Application definition\n\nINSTALLED_APPS = [\n    \"django.contrib.admin\",\n    \"django.contrib.auth\",\n    \"django.contrib.contenttypes\",\n    \"django.contrib.sessions\",\n    \"django.contrib.messages\",\n    \"django.contrib.staticfiles\",\n    \"housewatch.apps.HouseWatchConfig\",\n    \"rest_framework\",\n    \"corsheaders\",\n]\n\n\nMIDDLEWARE = [\n    \"django.middleware.security.SecurityMiddleware\",\n    \"corsheaders.middleware.CorsMiddleware\",\n    \"django.contrib.sessions.middleware.SessionMiddleware\",\n    \"django.middleware.common.CommonMiddleware\",\n    \"django.middleware.csrf.CsrfViewMiddleware\",\n    \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n    \"django.contrib.messages.middleware.MessageMiddleware\",\n    \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n]\n\nROOT_URLCONF = \"housewatch.urls\"\n\nTEMPLATES = [\n    {\n        \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n        \"DIRS\": [],\n        \"APP_DIRS\": True,\n        \"OPTIONS\": {\n            \"context_processors\": [\n                \"django.template.context_processors.debug\",\n                \"django.template.context_processors.request\",\n                \"django.contrib.auth.context_processors.auth\",\n                \"django.contrib.messages.context_processors.messages\",\n            ],\n        },\n    },\n]\n\nALLOWED_HOSTS = [\"*\"]\n\nCORS_ORIGIN_ALLOW_ALL = True\n\nWSGI_APPLICATION = \"housewatch.wsgi.application\"\n\n\nDATABASE_URL = get_from_env(\"DATABASE_URL\", \"\")\n\n\nif DATABASE_URL:\n    DATABASES = {\"default\": dj_database_url.config(default=DATABASE_URL, conn_max_age=600)}\nelif not DEBUG:\n    raise ImproperlyConfigured(\"DATABASE_URL environment variable not set!\")\n\n\n# Password validation\n# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n    },\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n    },\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n    },\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n    },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/4.1/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/4.1/howto/static-files/\n\nSTATIC_URL = \"/static/\"\nSTATIC_ROOT = os.path.join(BASE_DIR, \"staticfiles\")\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Django REST Framework\n# https://github.com/encode/django-rest-framework/\n\nREST_FRAMEWORK = {\n    \"DEFAULT_AUTHENTICATION_CLASSES\": [\n        # \"rest_framework.authentication.BasicAuthentication\",\n        # \"rest_framework.authentication.SessionAuthentication\",\n    ],\n    \"DEFAULT_PAGINATION_CLASS\": \"rest_framework.pagination.LimitOffsetPagination\",\n    \"DEFAULT_PERMISSION_CLASSES\": [],\n    \"DEFAULT_RENDERER_CLASSES\": [\"rest_framework.renderers.JSONRenderer\"],\n    \"PAGE_SIZE\": 100,\n    \"EXCEPTION_HANDLER\": \"exceptions_hog.exception_handler\",\n    \"TEST_REQUEST_DEFAULT_FORMAT\": \"json\",\n    \"DEFAULT_SCHEMA_CLASS\": \"drf_spectacular.openapi.AutoSchema\",\n}\n\n# See https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-DATABASE-DISABLE_SERVER_SIDE_CURSORS\nDEFAULT_AUTO_FIELD = \"django.db.models.AutoField\"\n\n# DRF Exceptions Hog\n# https://github.com/PostHog/drf-exceptions-hog#readme\n# EXCEPTIONS_HOG = {\n#     \"EXCEPTION_REPORTING\": \"housewatch.utils.exception_reporter\",\n# }\n\nEVENT_USAGE_CACHING_TTL = get_from_env(\"EVENT_USAGE_CACHING_TTL\", 12 * 60 * 60, type_cast=int)\n\n\nif TEST or DEBUG:\n    REDIS_URL = get_from_env(\"REDIS_URL\", \"redis://localhost:6379\")\n    RABBITMQ_URL = get_from_env(\"RABBITMQ_URL\", \"amqp://localhost:5672\")\nelse:\n    REDIS_URL = get_from_env(\"REDIS_URL\")\n    RABBITMQ_URL = get_from_env(\"RABBITMQ_URL\")\n\n\nCACHES = {\n    \"default\": {\n        \"BACKEND\": \"django_redis.cache.RedisCache\",\n        \"LOCATION\": REDIS_URL,\n        \"OPTIONS\": {\"CLIENT_CLASS\": \"django_redis.client.DefaultClient\"},\n        \"KEY_PREFIX\": \"housewatch\",\n    }\n}\n\n\n# Only listen to the default queue \"celery\", unless overridden via the CLI\nCELERY_QUEUES = (Queue(\"celery\", Exchange(\"celery\"), \"celery\"),)\nCELERY_DEFAULT_QUEUE = \"celery\"\nCELERY_IMPORTS = []  # type: ignore\nCELERY_BROKER_URL = RABBITMQ_URL  # celery connects to rabbitmq\nCELERY_BEAT_MAX_LOOP_INTERVAL = 30  # sleep max 30sec before checking for new periodic events\nCELERY_RESULT_BACKEND = REDIS_URL  # stores results for lookup when processing\nCELERY_IGNORE_RESULT = True  # only applies to delay(), must do @shared_task(ignore_result=True) for apply_async\nCELERY_RESULT_EXPIRES = timedelta(days=4)  # expire tasks after 4 days instead of the default 1\nREDBEAT_LOCK_TIMEOUT = 45  # keep distributed beat lock for 45sec\n\nif TEST:\n    import celery\n\n    celery.current_app.conf.task_always_eager = True\n    celery.current_app.conf.task_eager_propagates = True\n\n\nPOSTHOG_PROJECT_API_KEY = get_from_env(\"POSTHOG_PROJECT_API_KEY\", \"123456789\")\n\n\n# ClickHouse\n\nCLICKHOUSE_HOST = get_from_env(\"CLICKHOUSE_HOST\", \"localhost\")\nCLICKHOUSE_VERIFY = str_to_bool(get_from_env(\"CLICKHOUSE_VERIFY\", True))\nCLICKHOUSE_CA = get_from_env(\"CLICKHOUSE_CA\", optional=True)\nCLICKHOUSE_SECURE = str_to_bool(get_from_env(\"CLICKHOUSE_SECURE\", True))\nCLICKHOUSE_DATABASE = get_from_env(\"CLICKHOUSE_DATABASE\", \"default\")\nCLICKHOUSE_USER = get_from_env(\"CLICKHOUSE_USER\", \"default\")\nCLICKHOUSE_PASSWORD = get_from_env(\"CLICKHOUSE_PASSWORD\", \"\")\n\n\n# AWS settings for Backups\nAWS_ACCESS_KEY_ID = get_from_env(\"AWS_ACCESS_KEY_ID\", \"\")\nAWS_SECRET_ACCESS_KEY = get_from_env(\"AWS_SECRET_ACCESS_KEY\", \"\")\nAWS_DEFAULT_REGION = get_from_env(\"AWS_DEFAULT_REGION\", \"us-east-1\")\n"
  },
  {
    "path": "housewatch/settings/utils.py",
    "content": "import os\nfrom typing import Any, Callable, List, Optional\n\nfrom django.core.exceptions import ImproperlyConfigured\n\nfrom housewatch.utils import str_to_bool\n\n__all__ = [\"get_from_env\", \"get_list\", \"str_to_bool\"]\n\n\ndef get_from_env(key: str, default: Any = None, *, optional: bool = False, type_cast: Optional[Callable] = None) -> Any:\n    value = os.getenv(key)\n    if value is None or value == \"\":\n        if optional:\n            return None\n        if default is not None:\n            return default\n        else:\n            raise ImproperlyConfigured(f'The environment variable \"{key}\" is required to run HouseWatch!')\n    if type_cast is not None:\n        return type_cast(value)\n    return value\n\n\ndef get_list(text: str) -> List[str]:\n    if not text:\n        return []\n    return [item.strip() for item in text.split(\",\")]\n"
  },
  {
    "path": "housewatch/tasks/__init__.py",
    "content": "# Make tasks ready for celery autoimport\n\nfrom . import customer, report, usage\n\n__all__ = [\"customer\", \"usage\", \"report\", \"customer_report\"]\n"
  },
  {
    "path": "housewatch/tests/test_backup_table_fixture.sql",
    "content": "CREATE TABLE test_backup (\n    id UUID DEFAULT generateUUIDv4(),\n    name String,\n    timestamp DateTime DEFAULT now()\n) ENGINE = MergeTree()\nORDER BY id;\nINSERT INTO test_backup (name)\nSELECT substring(toString(rand() * 1000000000), 1, 5) AS random_string\nFROM numbers(100);"
  },
  {
    "path": "housewatch/urls.py",
    "content": "from django.contrib import admin\nfrom django.contrib.auth import views as auth_views\nfrom django.urls import path\nfrom rest_framework_extensions.routers import ExtendedDefaultRouter\nfrom housewatch.api.instance import InstanceViewset\nfrom housewatch.api.cluster import ClusterViewset\nfrom housewatch.api.backups import BackupViewset, ScheduledBackupViewset\nfrom housewatch.api.analyze import AnalyzeViewset\nfrom housewatch.api.async_migration import AsyncMigrationsViewset\nfrom housewatch.views import healthz\nfrom housewatch.api.saved_queries import SavedQueryViewset\n\n\nclass DefaultRouterPlusPlus(ExtendedDefaultRouter):\n    \"\"\"DefaultRouter with optional trailing slash and drf-extensions nesting.\"\"\"\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self.trailing_slash = r\"/?\"\n\n\nrouter = DefaultRouterPlusPlus()\nrouter.register(r\"api/instance\", InstanceViewset, basename=\"instance\")\nrouter.register(r\"api/clusters\", ClusterViewset, basename=\"cluster\")\nrouter.register(r\"api/backups\", BackupViewset, basename=\"backup\")\nrouter.register(r\"api/scheduled_backups\", ScheduledBackupViewset, basename=\"scheduled_backup\")\nrouter.register(r\"api/analyze\", AnalyzeViewset, basename=\"analyze\")\nrouter.register(r\"api/async_migrations\", AsyncMigrationsViewset, basename=\"async_migrations\")\nrouter.register(r\"api/saved_queries\", SavedQueryViewset, basename=\"saved_queries\")\nurlpatterns = [\n    path(\"admin/\", admin.site.urls),\n    path(\"healthz\", healthz, name=\"healthz\"),\n    path(\"logout\", auth_views.LogoutView.as_view()),\n    *router.urls,\n]\n"
  },
  {
    "path": "housewatch/utils/__init__.py",
    "content": "from typing import Any\n\n\ndef str_to_bool(value: Any) -> bool:\n    \"\"\"Return whether the provided string (or any value really) represents true. Otherwise false.\n    Just like plugin server stringToBoolean.\n    \"\"\"\n    if not value:\n        return False\n    return str(value).lower() in (\"y\", \"yes\", \"t\", \"true\", \"on\", \"1\")\n"
  },
  {
    "path": "housewatch/utils/encrypted_fields/fields.py",
    "content": "from cryptography.fernet import Fernet, MultiFernet\nfrom django.conf import settings\nfrom django.core.exceptions import FieldError, ImproperlyConfigured\nfrom django.db import models\nfrom django.utils.encoding import force_bytes, force_str\nfrom django.utils.functional import cached_property\n\nfrom . import hkdf\n\n\n__all__ = [\n    \"EncryptedField\",\n    \"EncryptedTextField\",\n    \"EncryptedCharField\",\n    \"EncryptedEmailField\",\n    \"EncryptedIntegerField\",\n    \"EncryptedDateField\",\n    \"EncryptedDateTimeField\",\n]\n\n\nclass EncryptedField(models.Field):\n    \"\"\"A field that encrypts values using Fernet symmetric encryption.\"\"\"\n\n    _internal_type = \"BinaryField\"\n\n    def __init__(self, *args, **kwargs):\n        if kwargs.get(\"primary_key\"):\n            raise ImproperlyConfigured(\"%s does not support primary_key=True.\" % self.__class__.__name__)\n        if kwargs.get(\"unique\"):\n            raise ImproperlyConfigured(\"%s does not support unique=True.\" % self.__class__.__name__)\n        if kwargs.get(\"db_index\"):\n            raise ImproperlyConfigured(\"%s does not support db_index=True.\" % self.__class__.__name__)\n        super(EncryptedField, self).__init__(*args, **kwargs)\n\n    @cached_property\n    def keys(self):\n        keys = getattr(settings, \"FERNET_KEYS\", None)\n        if keys is None:\n            keys = [settings.SECRET_KEY]\n        return keys\n\n    @cached_property\n    def fernet_keys(self):\n        if getattr(settings, \"FERNET_USE_HKDF\", True):\n            return [hkdf.derive_fernet_key(k) for k in self.keys]\n        return self.keys\n\n    @cached_property\n    def fernet(self):\n        if len(self.fernet_keys) == 1:\n            return Fernet(self.fernet_keys[0])\n        return MultiFernet([Fernet(k) for k in self.fernet_keys])\n\n    def get_internal_type(self):\n        return self._internal_type\n\n    def get_db_prep_save(self, value, connection):\n        value = super(EncryptedField, self).get_db_prep_save(value, connection)\n        if value is not None:\n            retval = self.fernet.encrypt(force_bytes(value))\n            return connection.Database.Binary(retval)\n\n    def from_db_value(self, value, expression, connection, *args):\n        if value is not None:\n            value = bytes(value)\n            return self.to_python(force_str(self.fernet.decrypt(value)))\n\n    @cached_property\n    def validators(self):\n        # Temporarily pretend to be whatever type of field we're masquerading\n        # as, for purposes of constructing validators (needed for\n        # IntegerField and subclasses).\n        self.__dict__[\"_internal_type\"] = super(EncryptedField, self).get_internal_type()\n        try:\n            return super(EncryptedField, self).validators\n        finally:\n            del self.__dict__[\"_internal_type\"]\n\n\ndef get_prep_lookup(self):\n    \"\"\"Raise errors for unsupported lookups\"\"\"\n    raise FieldError(\"{} '{}' does not support lookups\".format(self.lhs.field.__class__.__name__, self.lookup_name))\n\n\n# Register all field lookups (except 'isnull') to our handler\nfor name, lookup in models.Field.class_lookups.items():\n    # Dynamically create classes that inherit from the right lookups\n    if name != \"isnull\":\n        lookup_class = type(\"EncryptedField\" + name, (lookup,), {\"get_prep_lookup\": get_prep_lookup})\n        EncryptedField.register_lookup(lookup_class)\n\n\nclass EncryptedTextField(EncryptedField, models.TextField):\n    pass\n\n\nclass EncryptedCharField(EncryptedField, models.CharField):\n    pass\n\n\nclass EncryptedEmailField(EncryptedField, models.EmailField):\n    pass\n\n\nclass EncryptedIntegerField(EncryptedField, models.IntegerField):\n    pass\n\n\nclass EncryptedDateField(EncryptedField, models.DateField):\n    pass\n\n\nclass EncryptedDateTimeField(EncryptedField, models.DateTimeField):\n    pass\n"
  },
  {
    "path": "housewatch/utils/encrypted_fields/hkdf.py",
    "content": "import base64\n\nfrom cryptography.hazmat.primitives import hashes\nfrom cryptography.hazmat.primitives.kdf.hkdf import HKDF\nfrom cryptography.hazmat.backends import default_backend\nfrom django.utils.encoding import force_bytes\n\nbackend = default_backend()\ninfo = b\"django-fernet-fields\"\n# We need reproducible key derivation, so we can't use a random salt\nsalt = b\"django-fernet-fields-hkdf-salt\"\n\n\ndef derive_fernet_key(input_key):\n    \"\"\"Derive a 32-bit b64-encoded Fernet key from arbitrary input key.\"\"\"\n    hkdf = HKDF(\n        algorithm=hashes.SHA256(),\n        length=32,\n        salt=salt,\n        info=info,\n        backend=backend,\n    )\n    return base64.urlsafe_b64encode(hkdf.derive(force_bytes(input_key)))\n"
  },
  {
    "path": "housewatch/views.py",
    "content": "import structlog\nfrom django.contrib.auth.decorators import login_required\nfrom django.http import JsonResponse\n\nlogger = structlog.get_logger(__name__)\n\n\n@login_required\ndef homepage(request):\n    return JsonResponse({\"status\": \"ok\"})\n\n\ndef healthz(request):\n    return JsonResponse({\"status\": \"ok\"})\n"
  },
  {
    "path": "housewatch/wsgi.py",
    "content": "\"\"\"\nWSGI config for billing project.\n\nIt exposes the WSGI callable as a module-level variable named ``application``.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/\n\"\"\"\n\nimport os\n\nfrom django.core.wsgi import get_wsgi_application\n\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"housewatch.settings\")\n\napplication = get_wsgi_application()\n"
  },
  {
    "path": "manage.py",
    "content": "#!/usr/bin/env python\n\"\"\"Django's command-line utility for administrative tasks.\"\"\"\nimport os\nimport sys\n\n\ndef main():\n    \"\"\"Run administrative tasks.\"\"\"\n    os.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"housewatch.settings\")\n    try:\n        from django.core.management import execute_from_command_line\n    except ImportError as exc:\n        raise ImportError(\n            \"Couldn't import Django. Are you sure it's installed and \"\n            \"available on your PYTHONPATH environment variable? Did you \"\n            \"forget to activate a virtual environment?\"\n        ) from exc\n    execute_from_command_line(sys.argv)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "mypy.ini",
    "content": "[mypy]\nmypy_path = ./\nplugins =\n    mypy_django_plugin.main,\n    mypy_drf_plugin.main\nstrict_optional = True\nno_implicit_optional = True\nwarn_unused_ignores = True\ncheck_untyped_defs = True\nwarn_unreachable = True\nstrict_equality = True\nignore_missing_imports = True\n\n[mypy.plugins.django-stubs]\ndjango_settings_module = housewatch.settings\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.black]\nline-length = 120\ntarget-version = ['py38']\nexclude = '''\n/(\n    \\.git\n  | \\.mypy_cache\n  | \\.venv\n  | \\.env\n  | env\n  | migrations\n)/\n'''\n\n[tool.isort]\nmulti_line_output = 3\ninclude_trailing_comma = true\nforce_grid_wrap = 8\nensure_newline_before_comments = true\nline_length = 120\n\n[tool.ruff]\n# Enable flake8-bugbear (`B`) rules.\nselect = [\"E\", \"F\", \"B\"]\n\n# Never enforce `E501` (line length violations).\nignore = [\"E501\"]\n\n# Avoid trying to fix flake8-bugbear (`B`) violations.\nunfixable = [\"B\"]\n\n# Ignore `E402` (import violations) in all `__init__.py` files, and in `path/to/file.py`.\n[tool.ruff.per-file-ignores]\n\"__init__.py\" = [\"E402\"]\n\"path/to/file.py\" = [\"E402\"]\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\nenv =\n    DEBUG=1\n    TEST=1\nDJANGO_SETTINGS_MODULE = housewatch.settings\naddopts = -p no:warnings --reuse-db\n"
  },
  {
    "path": "requirements-dev.in",
    "content": "black==23.3.0\nruff==0.0.275\npip-tools==7.3.0\npre-commit==3.3.3\n"
  },
  {
    "path": "requirements-dev.txt",
    "content": "#\n# This file is autogenerated by pip-compile with Python 3.11\n# by the following command:\n#\n#    pip-compile requirements-dev.in\n#\nblack==23.3.0\n    # via -r requirements-dev.in\nbuild==0.10.0\n    # via pip-tools\ncfgv==3.3.1\n    # via pre-commit\nclick==8.1.3\n    # via\n    #   black\n    #   pip-tools\ndistlib==0.3.6\n    # via virtualenv\nfilelock==3.12.2\n    # via virtualenv\nidentify==2.5.24\n    # via pre-commit\nmypy-extensions==1.0.0\n    # via black\nnodeenv==1.8.0\n    # via pre-commit\npackaging==23.1\n    # via\n    #   black\n    #   build\npathspec==0.11.1\n    # via black\npip-tools==7.3.0\n    # via -r requirements-dev.in\nplatformdirs==3.8.0\n    # via\n    #   black\n    #   virtualenv\npre-commit==3.3.3\n    # via -r requirements-dev.in\npyproject-hooks==1.0.0\n    # via build\npyyaml==6.0\n    # via pre-commit\nruff==0.0.275\n    # via -r requirements-dev.in\nvirtualenv==20.23.1\n    # via pre-commit\nwheel==0.40.0\n    # via pip-tools\n\n# The following packages are considered to be unsafe in a requirements file:\n# pip\n# setuptools\n"
  },
  {
    "path": "requirements.in",
    "content": "aiohttp==3.8.4\naiosignal==1.3.1\namqp==5.1.1\nasgiref==3.6.0\nasync-timeout==4.0.2\nattrs==22.2.0\nbilliard==3.6.4.0\ncelery==5.2.7\ncertifi==2022.12.7\ncharset-normalizer==3.1.0\nclick==8.1.3\nclick-didyoumean==0.3.0\nclick-plugins==1.1.1\nclick-repl==0.2.0\nclickhouse-driver==0.2.6\nclickhouse-pool==0.5.3\ncroniter==1.4.1\ncryptography>=0.9\ndj-database-url==1.0.0\nDjango==4.1.1\ndjango-cors-headers==3.13.0\ndjango-ipware==5.0.0\ndjango-redis==5.2.0\ndjango-structlog==3.0.1\ndjangorestframework==3.14.0\ndrf-exceptions-hog==0.2.0\ndrf-extensions==0.7.1\ndrf-spectacular==0.24.2\nfrozenlist==1.3.3\ngunicorn==20.1.0\nidna==3.4\ninflection==0.5.1\njsonschema==4.17.3\nkombu==5.2.4\nmultidict==6.0.4\nopenai==0.27.8\nprompt-toolkit==3.0.38\npsycopg2-binary==2.9.7\npyrsistent==0.19.3\npytz==2023.2\npytz-deprecation-shim==0.1.0.post0\nPyYAML==6.0\nredis==4.5.3\nrequests==2.31.0\nsix==1.16.0\nsqlparse==0.4.3\nstructlog==22.3.0\ntqdm==4.65.0\ntzdata==2023.3\ntzlocal==4.3\nuritemplate==4.1.1\nurllib3==1.26.15\nvine==5.0.0\nwcwidth==0.2.6\nwhitenoise==5.2.0\nyarl==1.9.2\nsentry-sdk==1.31.0"
  },
  {
    "path": "requirements.txt",
    "content": "#\n# This file is autogenerated by pip-compile with Python 3.11\n# by the following command:\n#\n#    pip-compile requirements.in\n#\naiohttp==3.8.4\n    # via\n    #   -r requirements.in\n    #   openai\naiosignal==1.3.1\n    # via\n    #   -r requirements.in\n    #   aiohttp\namqp==5.1.1\n    # via\n    #   -r requirements.in\n    #   kombu\nasgiref==3.6.0\n    # via\n    #   -r requirements.in\n    #   django\nasync-timeout==4.0.2\n    # via\n    #   -r requirements.in\n    #   aiohttp\nattrs==22.2.0\n    # via\n    #   -r requirements.in\n    #   aiohttp\n    #   jsonschema\nbilliard==3.6.4.0\n    # via\n    #   -r requirements.in\n    #   celery\ncelery==5.2.7\n    # via -r requirements.in\ncertifi==2022.12.7\n    # via\n    #   -r requirements.in\n    #   requests\n    #   sentry-sdk\ncffi==1.15.1\n    # via cryptography\ncharset-normalizer==3.1.0\n    # via\n    #   -r requirements.in\n    #   aiohttp\n    #   requests\nclick==8.1.3\n    # via\n    #   -r requirements.in\n    #   celery\n    #   click-didyoumean\n    #   click-plugins\n    #   click-repl\nclick-didyoumean==0.3.0\n    # via\n    #   -r requirements.in\n    #   celery\nclick-plugins==1.1.1\n    # via\n    #   -r requirements.in\n    #   celery\nclick-repl==0.2.0\n    # via\n    #   -r requirements.in\n    #   celery\nclickhouse-driver==0.2.6\n    # via\n    #   -r requirements.in\n    #   clickhouse-pool\nclickhouse-pool==0.5.3\n    # via -r requirements.in\ncroniter==1.4.1\n    # via -r requirements.in\ncryptography==41.0.3\n    # via -r requirements.in\ndj-database-url==1.0.0\n    # via -r requirements.in\ndjango==4.1.1\n    # via\n    #   -r requirements.in\n    #   dj-database-url\n    #   django-cors-headers\n    #   django-redis\n    #   django-structlog\n    #   djangorestframework\n    #   drf-spectacular\ndjango-cors-headers==3.13.0\n    # via -r requirements.in\ndjango-ipware==5.0.0\n    # via\n    #   -r requirements.in\n    #   django-structlog\ndjango-redis==5.2.0\n    # via -r requirements.in\ndjango-structlog==3.0.1\n    # via -r requirements.in\ndjangorestframework==3.14.0\n    # via\n    #   -r requirements.in\n    #   drf-exceptions-hog\n    #   drf-extensions\n    #   drf-spectacular\ndrf-exceptions-hog==0.2.0\n    # via -r requirements.in\ndrf-extensions==0.7.1\n    # via -r requirements.in\ndrf-spectacular==0.24.2\n    # via -r requirements.in\nfrozenlist==1.3.3\n    # via\n    #   -r requirements.in\n    #   aiohttp\n    #   aiosignal\ngunicorn==20.1.0\n    # via -r requirements.in\nidna==3.4\n    # via\n    #   -r requirements.in\n    #   requests\n    #   yarl\ninflection==0.5.1\n    # via\n    #   -r requirements.in\n    #   drf-spectacular\njsonschema==4.17.3\n    # via\n    #   -r requirements.in\n    #   drf-spectacular\nkombu==5.2.4\n    # via\n    #   -r requirements.in\n    #   celery\nmultidict==6.0.4\n    # via\n    #   -r requirements.in\n    #   aiohttp\n    #   yarl\nopenai==0.27.8\n    # via -r requirements.in\nprompt-toolkit==3.0.38\n    # via\n    #   -r requirements.in\n    #   click-repl\npsycopg2-binary==2.9.7\n    # via -r requirements.in\npycparser==2.21\n    # via cffi\npyrsistent==0.19.3\n    # via\n    #   -r requirements.in\n    #   jsonschema\npython-dateutil==2.8.2\n    # via croniter\npytz==2023.2\n    # via\n    #   -r requirements.in\n    #   celery\n    #   clickhouse-driver\n    #   djangorestframework\npytz-deprecation-shim==0.1.0.post0\n    # via\n    #   -r requirements.in\n    #   tzlocal\npyyaml==6.0\n    # via\n    #   -r requirements.in\n    #   drf-spectacular\nredis==4.5.3\n    # via\n    #   -r requirements.in\n    #   django-redis\nrequests==2.31.0\n    # via\n    #   -r requirements.in\n    #   openai\nsentry-sdk==1.31.0\n    # via -r requirements.in\nsix==1.16.0\n    # via\n    #   -r requirements.in\n    #   click-repl\n    #   python-dateutil\nsqlparse==0.4.3\n    # via\n    #   -r requirements.in\n    #   django\nstructlog==22.3.0\n    # via\n    #   -r requirements.in\n    #   django-structlog\ntqdm==4.65.0\n    # via\n    #   -r requirements.in\n    #   openai\ntzdata==2023.3\n    # via\n    #   -r requirements.in\n    #   pytz-deprecation-shim\ntzlocal==4.3\n    # via\n    #   -r requirements.in\n    #   clickhouse-driver\nuritemplate==4.1.1\n    # via\n    #   -r requirements.in\n    #   drf-spectacular\nurllib3==1.26.15\n    # via\n    #   -r requirements.in\n    #   requests\n    #   sentry-sdk\nvine==5.0.0\n    # via\n    #   -r requirements.in\n    #   amqp\n    #   celery\n    #   kombu\nwcwidth==0.2.6\n    # via\n    #   -r requirements.in\n    #   prompt-toolkit\nwhitenoise==5.2.0\n    # via -r requirements.in\nyarl==1.9.2\n    # via\n    #   -r requirements.in\n    #   aiohttp\n\n# The following packages are considered to be unsafe in a requirements file:\n# setuptools\n"
  },
  {
    "path": "runtime.txt",
    "content": "python-3.8.10\n"
  }
]