diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..417b368 --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,34 @@ +name: Build and Push Docker Image + +on: + push: + paths: + - 'frontend/**' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./frontend + platforms: linux/amd64,linux/arm64 + push: true + tags: morauen/oxn-frontend:latest \ No newline at end of file diff --git a/.gitignore b/.gitignore index aca3bd2..ee712ff 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,15 @@ evaluation.py *.pickle kubevpn.exe venv + +# terraform +terraform.tfstate +terraform.tfstate.backup +.terraform.lock.hcl +.terraform + +# logs +*.log + +# Cluster configuration +config/.cluster-prefix \ No newline at end of file diff --git a/backend/db.sqlite3 b/backend/db.sqlite3 new file mode 100644 index 0000000..e69de29 diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..8926ee0 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'oxn_backend.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/oxn_backend/__init__.py b/backend/oxn_backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/oxn_backend/asgi.py b/backend/oxn_backend/asgi.py new file mode 100644 index 0000000..8d4f159 --- /dev/null +++ b/backend/oxn_backend/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for oxn_backend project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'oxn_backend.settings') + +application = get_asgi_application() diff --git a/backend/oxn_backend/settings.py b/backend/oxn_backend/settings.py new file mode 100644 index 0000000..b275b12 --- /dev/null +++ b/backend/oxn_backend/settings.py @@ -0,0 +1,124 @@ +""" +Django settings for oxn_backend project. + +Generated by 'django-admin startproject' using Django 5.1.3. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-3ex&@i1%1418$p_nw@(vas8z03maau+z29++c*v$x9=wu-&qjk' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'oxn_logic' +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'oxn_backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'oxn_backend.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/backend/oxn_backend/urls.py b/backend/oxn_backend/urls.py new file mode 100644 index 0000000..f154947 --- /dev/null +++ b/backend/oxn_backend/urls.py @@ -0,0 +1,24 @@ +""" +URL configuration for oxn_backend project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include + + + +urlpatterns = [ + path('', include('oxn_logic.urls')) +] diff --git a/backend/oxn_backend/wsgi.py b/backend/oxn_backend/wsgi.py new file mode 100644 index 0000000..98eb892 --- /dev/null +++ b/backend/oxn_backend/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for oxn_backend project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'oxn_backend.settings') + +application = get_wsgi_application() diff --git a/backend/oxn_logic/__init__.py b/backend/oxn_logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/oxn_logic/admin.py b/backend/oxn_logic/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/oxn_logic/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/oxn_logic/apps.py b/backend/oxn_logic/apps.py new file mode 100644 index 0000000..3bc9e14 --- /dev/null +++ b/backend/oxn_logic/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OxnLogicConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'oxn_logic' diff --git a/backend/oxn_logic/migrations/__init__.py b/backend/oxn_logic/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/oxn_logic/models.py b/backend/oxn_logic/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/backend/oxn_logic/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/oxn_logic/tests.py b/backend/oxn_logic/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/oxn_logic/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/oxn_logic/urls.py b/backend/oxn_logic/urls.py new file mode 100644 index 0000000..3d38d29 --- /dev/null +++ b/backend/oxn_logic/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('api/helloworld', views.hello_world, name='hello_world'), +] \ No newline at end of file diff --git a/backend/oxn_logic/views.py b/backend/oxn_logic/views.py new file mode 100644 index 0000000..015afbe --- /dev/null +++ b/backend/oxn_logic/views.py @@ -0,0 +1,7 @@ +from django.shortcuts import render +from django.http import HttpResponse + +# Create your views here. + +def hello_world(request): + return HttpResponse("Hello world") diff --git a/backend/readme.md b/backend/readme.md new file mode 100644 index 0000000..2b8253b --- /dev/null +++ b/backend/readme.md @@ -0,0 +1,12 @@ +# This is the backend Documentation for OXN + +1. make sure Django is included into the setup.cfg + +2. How to add routes: + + 1. to add routes update the oxn_logic.views.py file with the code + 2. map the code to some url in the oxn_logic.urls.py file + +3. run the server on localhost from backend directory + + python manage.py runserver \ No newline at end of file diff --git a/documentation/Architecute-building-blocks.drawio b/documentation/Architecute-building-blocks.drawio new file mode 100644 index 0000000..00fa54f --- /dev/null +++ b/documentation/Architecute-building-blocks.drawio @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/experiments/recommendation_delay90_A.yml b/experiments/OLD_recommendation_delay90_A.yml similarity index 100% rename from experiments/recommendation_delay90_A.yml rename to experiments/OLD_recommendation_delay90_A.yml diff --git a/experiments/recommendation_delay90_B.yml b/experiments/OLD_recommendation_delay90_B.yml similarity index 100% rename from experiments/recommendation_delay90_B.yml rename to experiments/OLD_recommendation_delay90_B.yml diff --git a/experiments/recommendation_delay90_C.yml b/experiments/OLD_recommendation_delay90_C.yml similarity index 100% rename from experiments/recommendation_delay90_C.yml rename to experiments/OLD_recommendation_delay90_C.yml diff --git a/experiments/recommendation_delay90_baseline.yml b/experiments/OLD_recommendation_delay90_baseline.yml similarity index 100% rename from experiments/recommendation_delay90_baseline.yml rename to experiments/OLD_recommendation_delay90_baseline.yml diff --git a/experiments/recommendation_loss15_A.yml b/experiments/OLD_recommendation_loss15_A.yml similarity index 100% rename from experiments/recommendation_loss15_A.yml rename to experiments/OLD_recommendation_loss15_A.yml diff --git a/experiments/recommendation_loss15_B.yml b/experiments/OLD_recommendation_loss15_B.yml similarity index 100% rename from experiments/recommendation_loss15_B.yml rename to experiments/OLD_recommendation_loss15_B.yml diff --git a/experiments/recommendation_loss15_C.yml b/experiments/OLD_recommendation_loss15_C.yml similarity index 100% rename from experiments/recommendation_loss15_C.yml rename to experiments/OLD_recommendation_loss15_C.yml diff --git a/experiments/recommendation_loss15_baseline.yml b/experiments/OLD_recommendation_loss15_baseline.yml similarity index 100% rename from experiments/recommendation_loss15_baseline.yml rename to experiments/OLD_recommendation_loss15_baseline.yml diff --git a/experiments/recommendation_pause_baseline.yml b/experiments/OLD_recommendation_pause_baseline.yml similarity index 100% rename from experiments/recommendation_pause_baseline.yml rename to experiments/OLD_recommendation_pause_baseline.yml diff --git a/experiments/test.yml b/experiments/OLD_test.yml similarity index 98% rename from experiments/test.yml rename to experiments/OLD_test.yml index 537cf75..8c49521 100644 --- a/experiments/test.yml +++ b/experiments/OLD_test.yml @@ -177,8 +177,8 @@ experiment: max_users: 500 spawn_rate: 10 locust_files: [ - { path: locust/locust_basic_interaction.py }, - { path: locust/locust_otel_demo.py }, + { path: /opt/oxn/locust/locust_basic_interaction.py }, + { path: /opt/oxn/locust/locust_otel_demo.py }, ] target: name: astronomy-shop-frontendproxy diff --git a/experiments/big.yml b/experiments/big.yml new file mode 100644 index 0000000..c15ed29 --- /dev/null +++ b/experiments/big.yml @@ -0,0 +1,180 @@ +# yaml-language-server: $schema=./experiment_schema.json +experiment: + name: big + version: 0.0.1 + orchestrator: kubernetes + services: + jaeger: + name: astronomy-shop-jaeger-query + namespace: system-under-evaluation + prometheus: + [ + { + name: astronomy-shop-prometheus-server, + namespace: system-under-evaluation, + target: sue, + }, + { + name: kube-prometheus-kube-prome-prometheus, + namespace: oxn-external-monitoring, + target: oxn, + }, + ] + responses: + - name: frontend_traces + type: trace + service_name: frontend + left_window: 10s + right_window: 10s + limit: 1 + - name: system_CPU + type: metric + metric_name: sum(rate(container_cpu_usage_seconds_total{namespace="system-under-evaluation"}[1m])) + left_window: 10s + right_window: 10s + step: 1 + target: oxn + - name: recommendation_deployment_CPU + type: metric + metric_name: sum(rate(container_cpu_usage_seconds_total{namespace="system-under-evaluation", pod=~"astronomy-shop-recommendationservice.*"}[90s])) by (pod) + left_window: 10s + right_window: 10s + step: 1 + target: oxn + - name: frontend_http_latency + type: metric + metric_name: histogram_quantile(0.95, sum(rate(http_server_duration_milliseconds_bucket{job="opentelemetry-demo/frontend"}[90s])) by (http_method, http_status_code, le)) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: cart_service_latency + type: metric + metric_name: histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket{job="opentelemetry-demo/cartservice"}[90s])) by (http_route, le)) * 1000 + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: product_catalog_latency + type: metric + metric_name: histogram_quantile(0.95, sum(rate(rpc_server_duration_milliseconds_bucket{job="opentelemetry-demo/productcatalogservice"}[90s])) by (rpc_method, le)) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: pod_status + type: metric + metric_name: sum by (phase) (kube_pod_status_phase{namespace="system-under-evaluation"}) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: pod_restarts + type: metric + metric_name: sum(kube_pod_container_status_restarts_total{namespace="system-under-evaluation"}) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: terminated_pods + type: metric + metric_name: sum(kube_pod_container_status_terminated{namespace="system-under-evaluation"}) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: failed_spans + type: metric + metric_name: sum(rate(otelcol_exporter_send_failed_spans[1m])) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: network_bytes + type: metric + metric_name: sum(rate(node_network_receive_bytes_total[1m]) + rate(node_network_transmit_bytes_total[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: network_drops + type: metric + metric_name: sum(rate(node_network_receive_drop_total[1m]) + rate(node_network_transmit_drop_total[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: network_errors + type: metric + metric_name: sum(rate(node_network_receive_errs_total[1m]) + rate(node_network_transmit_errs_total[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: tcp_retransmissions + type: metric + metric_name: sum(rate(node_netstat_Tcp_RetransSegs[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: tcp_udp_errors + type: metric + metric_name: sum(rate(node_netstat_Tcp_InErrs[1m]) + rate(node_netstat_Udp_InErrors[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: node_load + type: metric + metric_name: node_load1 + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: memory_available + type: metric + metric_name: node_memory_MemAvailable_bytes + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: cpu_usage + type: metric + metric_name: sum(rate(node_cpu_seconds_total{mode!="idle"}[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: tcp_connections + type: metric + metric_name: node_netstat_Tcp_CurrEstab + left_window: 10s + right_window: 10s + step: 1 + target: sue + treatments: + - empty_treatment: + action: empty + params: { duration: 1m } + sue: + compose: opentelemetry-demo/docker-compose.yml + exclude: [loadgenerator] + required: + [ + { + namespace: system-under-evaluation, + name: astronomy-shop-prometheus-server, + }, + ] + loadgen: + run_time: 20m + max_users: 500 + spawn_rate: 50 + locust_files: + - "/opt/oxn/locust/locust_basic_interaction.py" + - "/opt/oxn/locust/locust_otel_demo.py" + target: + name: astronomy-shop-frontendproxy + namespace: system-under-evaluation + port: 8080 diff --git a/experiments/debug_Experiment_packet_loss.yml b/experiments/debug_Experiment_packet_loss.yml new file mode 100644 index 0000000..85c4a79 --- /dev/null +++ b/experiments/debug_Experiment_packet_loss.yml @@ -0,0 +1,48 @@ +# injects a 120s pause in the recomendation service +experiment: + responses: + - frontend_traces: + type: trace + service_name: frontend + left_window: 240s + right_window: 240s + limit: 100000 + - recommendation_traces: + type: trace + service_name: recommendationservice + left_window: 240s + right_window: 240s + limit: 100000 + - system_CPU: + type: metric + metric_name: sum(rate(container_cpu_usage_seconds_total{container_label_com_docker_compose_project="opentelemetry-demo"}[1m])) + left_window: 240s + right_window: 240s + step: 1 + - recommendations_total: + type: metric + metric_name: increase(app_recommendations_counter_total[90s]) + left_window: 240s + right_window: 240s + step: 1 + treatments: + - packet_loss_treatment: + action: pause + params: { + service_name: recommendation-service, + duration: 30s, + } + sue: + compose: opentelemetry-demo/docker-compose.yml + exclude: [loadgenerator] + loadgen: + run_time: 1m + stages: + - {duration: 600, users: 50, spawn_rate: 25} + tasks: + - { endpoint: /, verb: get, weight: 1, params: { } } + - { endpoint: /api/products/0PUK6V6EV0, verb: get, weight: 10, params: { } } + - { endpoint: /api/recommendations, verb: get, weight: 3, params: { "productIds": ["1YMWWN1N4O"]}} + - { endpoint: /api/cart, verb: get, weight: 3, params: { } } + - { endpoint: /api/data, verb: get, weight: 3, params: { "contextKeys": [ "accessories" ] } } + - { endpoint: /api/cart, verb: post, weight: 2, params: { "item": {"productId":"6E92ZMYYFZ", "quantity":2, }, "userId":'ab2d0fc0-7224-11ec-8ef2-b658b885fb3',} } \ No newline at end of file diff --git a/experiments/experiment_new.yml b/experiments/experiment_new.yml new file mode 100644 index 0000000..91c400b --- /dev/null +++ b/experiments/experiment_new.yml @@ -0,0 +1,227 @@ +# yaml-language-server: $schema=experiment_schema.json +experiment: + name: "latest" + version: 0.0.1 + orchestrator: kubernetes + services: + jaeger: + name: astronomy-shop-jaeger-query + namespace: system-under-evaluation + prometheus: + [ + { + name: astronomy-shop-prometheus-server, + namespace: system-under-evaluation, + target: sue, + }, + { + name: kube-prometheus-kube-prome-prometheus, + namespace: oxn-external-monitoring, + target: oxn, + }, + ] + responses: + - name: frontend_traces + type: trace + service_name: frontend + left_window: 10s + right_window: 10s + limit: 1 + - name: system_CPU + type: metric + metric_name: sum(rate(container_cpu_usage_seconds_total{namespace="system-under-evaluation"}[1m])) + left_window: 10s + right_window: 10s + step: 1 + target: oxn + - name: recommendation_deployment_CPU + type: metric + metric_name: sum(rate(container_cpu_usage_seconds_total{namespace="system-under-evaluation", pod=~"astronomy-shop-recommendationservice.*"}[90s])) by (pod) + left_window: 10s + right_window: 10s + step: 1 + target: oxn + - name: frontend_http_latency + type: metric + metric_name: histogram_quantile(0.95, sum(rate(http_server_duration_milliseconds_bucket{job="opentelemetry-demo/frontend"}[90s])) by (http_method, http_status_code, le)) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: cart_service_latency + type: metric + metric_name: histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket{job="opentelemetry-demo/cartservice"}[90s])) by (http_route, le)) * 1000 + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: product_catalog_latency + type: metric + metric_name: histogram_quantile(0.95, sum(rate(rpc_server_duration_milliseconds_bucket{job="opentelemetry-demo/productcatalogservice"}[90s])) by (rpc_method, le)) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: pod_status + type: metric + metric_name: sum by (phase) (kube_pod_status_phase{namespace="system-under-evaluation"}) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: pod_restarts + type: metric + metric_name: sum(kube_pod_container_status_restarts_total{namespace="system-under-evaluation"}) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: terminated_pods + type: metric + metric_name: sum(kube_pod_container_status_terminated{namespace="system-under-evaluation"}) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: failed_spans + type: metric + metric_name: sum(rate(otelcol_exporter_send_failed_spans[1m])) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: network_bytes + type: metric + metric_name: sum(rate(node_network_receive_bytes_total[1m]) + rate(node_network_transmit_bytes_total[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: network_drops + type: metric + metric_name: sum(rate(node_network_receive_drop_total[1m]) + rate(node_network_transmit_drop_total[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: network_errors + type: metric + metric_name: sum(rate(node_network_receive_errs_total[1m]) + rate(node_network_transmit_errs_total[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: tcp_retransmissions + type: metric + metric_name: sum(rate(node_netstat_Tcp_RetransSegs[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: tcp_udp_errors + type: metric + metric_name: sum(rate(node_netstat_Tcp_InErrs[1m]) + rate(node_netstat_Udp_InErrors[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: node_load + type: metric + metric_name: node_load1 + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: memory_available + type: metric + metric_name: node_memory_MemAvailable_bytes + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: cpu_usage + type: metric + metric_name: sum(rate(node_cpu_seconds_total{mode!="idle"}[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: tcp_connections + type: metric + metric_name: node_netstat_Tcp_CurrEstab + left_window: 10s + right_window: 10s + step: 1 + target: sue + treatments: + - add_security_context: + action: security_context_kubernetes + params: + { + namespace: system-under-evaluation, + label_selector: app.kubernetes.io/component, + label: recommendationservice, + capabilities: { add: ["NET_ADMIN"] }, + } + - delay_treatment: + action: delay + params: { + namespace: system-under-evaluation, + label_selector: app.kubernetes.io/name, + label: astronomy-shop-recommendationservice, + #service_name: node-exporter, + delay_time: 45ms, + delay_jitter: 45ms, + duration: 2m, + interface: eth0, + } + - probabilistic_head_sampling_rate: + action: kube_probl + params: { sampling_percentage: 5.0, hash_seed: 22 } + - package_lost_treatment: + action: kubernetes_loss + params: + { + namespace: system-under-evaluation, + label_selector: app.kubernetes.io/name, + label: astronomy-shop-recommendationservice, + loss_percentage: 15.0, + duration: 1m, + interface: eth0, + } + - empty_treatment: + action: empty + params: { duration: 5m } + - increase_otel_metric_interval: + action: kubernetes_otel_metrics_interval + params: + { + namespace: system-under-evaluation, + label_selector: app.kubernetes.io/component, + label: recommendationservice, + interval: 15s, + } + - prometheus_scrape_interval: + action: kubernetes_prometheus_interval + params: { interval: 5s, evaluation_interval: 5s, scrape_timeout: 3s } + sue: + compose: opentelemetry-demo/docker-compose.yml + exclude: [loadgenerator] + required: [ + { + namespace: system-under-evaluation, + name: astronomy-shop-prometheus-server, + }, + ] # {namespace: monitoring, name: not-running-service} + #required: [{namespace: monitoring, name: grafana}, {namespace: monitoring, name: node-exporter}] + loadgen: + run_time: 5m + max_users: 50 + spawn_rate: 10 + locust_files: + - "/opt/oxn/locust/locust_basic_interaction.py" + - "/opt/oxn/locust/locust_otel_demo.py" + target: + name: astronomy-shop-frontendproxy + namespace: system-under-evaluation + port: 8080 diff --git a/experiments/experiment_schema.json b/experiments/experiment_schema.json new file mode 100644 index 0000000..ec33296 --- /dev/null +++ b/experiments/experiment_schema.json @@ -0,0 +1,255 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "experiment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "orchestrator": { + "type": "string" + }, + "services": { + "type": "object", + "properties": { + "jaeger": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "name", + "namespace" + ] + }, + "prometheus": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "name", + "namespace", + "target" + ] + } + } + }, + "required": [ + "jaeger", + "prometheus" + ] + }, + "responses": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "target": { + "type": "string" + }, + "metric_name": { + "type": "string" + }, + "type": { + "const": "metric" + }, + "step": { + "type": "integer" + }, + "left_window": { + "type": "string" + }, + "right_window": { + "type": "string" + } + }, + "required": [ + "name", + "target", + "metric_name", + "type", + "step", + "left_window", + "right_window" + ] + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "trace" + }, + "service_name": { + "type": "string" + }, + "left_window": { + "type": "string" + }, + "right_window": { + "type": "string" + }, + "limit": { + "type": "integer" + } + }, + "required": [ + "name", + "type", + "service_name", + "left_window", + "right_window" + ] + } + ] + } + }, + "treatments": { + "type": "array", + "items": { + "type": "object", + "patternProperties": { + "^.*$": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "params": { + "type": "object" + } + }, + "required": [ + "action", + "params" + ] + } + }, + "additionalProperties": false + } + }, + "sue": { + "type": "object", + "properties": { + "compose": { + "type": "string" + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "type": "array", + "items": { + "type": "string" + } + }, + "required": { + "type": "array", + "items": { + "type": "object", + "properties": { + "namespace": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "namespace", + "name" + ] + } + } + }, + "required": [ + "compose" + ] + }, + "loadgen": { + "type": "object", + "properties": { + "run_time": { + "type": "string" + }, + "max_users": { + "type": "integer" + }, + "spawn_rate": { + "type": "integer" + }, + "locust_files": { + "type": "array", + "items": { + "type": "string" + } + }, + "target": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "port": { + "type": "integer" + } + }, + "required": [ + "name", + "namespace", + "port" + ] + } + }, + "required": [ + "run_time" + ] + } + }, + "required": [ + "version", + "orchestrator", + "responses", + "sue", + "loadgen" + ] + } + }, + "required": [ + "experiment" + ] +} \ No newline at end of file diff --git a/experiments/latest.yml b/experiments/latest.yml new file mode 100644 index 0000000..8594a3f --- /dev/null +++ b/experiments/latest.yml @@ -0,0 +1,245 @@ +# yaml-language-server: $schema=experiment_schema.json +experiment: + name: "latest" + version: 0.0.1 + orchestrator: kubernetes + services: + jaeger: + name: astronomy-shop-jaeger-query + namespace: system-under-evaluation + prometheus: + [ + { + name: astronomy-shop-prometheus-server, + namespace: system-under-evaluation, + target: sue, + }, + { + name: kube-prometheus-kube-prome-prometheus, + namespace: oxn-external-monitoring, + target: oxn, + }, + ] + responses: + - name: frontend_traces + type: trace + service_name: frontend + left_window: 10s + right_window: 10s + limit: 1 + - name: system_CPU + type: metric + metric_name: sum(rate(container_cpu_usage_seconds_total{namespace="system-under-evaluation"}[1m])) + left_window: 10s + right_window: 10s + step: 1 + target: oxn + - name: recommendation_deployment_CPU + type: metric + metric_name: sum(rate(container_cpu_usage_seconds_total{namespace="system-under-evaluation", pod=~"astronomy-shop-recommendationservice.*"}[90s])) by (pod) + left_window: 10s + right_window: 10s + step: 1 + target: oxn + - name: frontend_http_latency + type: metric + metric_name: histogram_quantile(0.95, sum(rate(http_server_duration_milliseconds_bucket{job="opentelemetry-demo/frontend"}[90s])) by (http_method, http_status_code, le)) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: cart_service_latency + type: metric + metric_name: histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket{job="opentelemetry-demo/cartservice"}[90s])) by (http_route, le)) * 1000 + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: product_catalog_latency + type: metric + metric_name: histogram_quantile(0.95, sum(rate(rpc_server_duration_milliseconds_bucket{job="opentelemetry-demo/productcatalogservice"}[90s])) by (rpc_method, le)) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: pod_status + type: metric + metric_name: sum by (phase) (kube_pod_status_phase{namespace="system-under-evaluation"}) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: pod_restarts + type: metric + metric_name: sum(kube_pod_container_status_restarts_total{namespace="system-under-evaluation"}) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: terminated_pods + type: metric + metric_name: sum(kube_pod_container_status_terminated{namespace="system-under-evaluation"}) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: failed_spans + type: metric + metric_name: sum(rate(otelcol_exporter_send_failed_spans[1m])) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: network_bytes + type: metric + metric_name: sum(rate(node_network_receive_bytes_total[1m]) + rate(node_network_transmit_bytes_total[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: network_drops + type: metric + metric_name: sum(rate(node_network_receive_drop_total[1m]) + rate(node_network_transmit_drop_total[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: network_errors + type: metric + metric_name: sum(rate(node_network_receive_errs_total[1m]) + rate(node_network_transmit_errs_total[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: tcp_retransmissions + type: metric + metric_name: sum(rate(node_netstat_Tcp_RetransSegs[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: tcp_udp_errors + type: metric + metric_name: sum(rate(node_netstat_Tcp_InErrs[1m]) + rate(node_netstat_Udp_InErrors[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: node_load + type: metric + metric_name: node_load1 + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: memory_available + type: metric + metric_name: node_memory_MemAvailable_bytes + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: cpu_usage + type: metric + metric_name: sum(rate(node_cpu_seconds_total{mode!="idle"}[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: tcp_connections + type: metric + metric_name: node_netstat_Tcp_CurrEstab + left_window: 10s + right_window: 10s + step: 1 + target: sue + treatments: + - empty_treatment: + action: empty + params: { duration: 5m } + #- stop_loadgen_deployment: + # action: scale_deployment + # params: { + # namespace: system-under-evaluation, + # label_selector: app.kubernetes.io/component, + # label: loadgenerator, + # scale_to: 0, + # } + #- add_security_context: + # action: security_context_kubernetes + # params: { + # namespace: system-under-evaluation, + # label_selector: app.kubernetes.io/component, + # label: recommendationservice, + # capabilities: { add: ["NET_ADMIN"] }, + # } + # - delay_treatment: + # action: delay + # params: { + # namespace: system-under-evaluation, + # label_selector: app.kubernetes.io/name, + # label: astronomy-shop-recommendationservice, + # #service_name: node-exporter, + # delay_time: 45ms, + # delay_jitter: 45ms, + # duration: 2m, + # interface: eth0, + # } + # - probabilistic_head_sampling_rate: + # action: kube_probl + # params: { + # sampling_percentage: 5.0, + # hash_seed: 22, + # } + #- package_lost_treatment: + # action: kubernetes_loss + # params: { + # namespace: system-under-evaluation, + # label_selector: app.kubernetes.io/name, + # label: astronomy-shop-recommendationservice, + # loss_percentage: 15.0, + # duration: 10m, + # interface: eth0, + # } + #- empty_treatment: + # action: empty + # params: { + # duration: 5m, + # } + #- increase_otel_metric_interval: + # action: kubernetes_otel_metrics_interval + # params: { + # namespace: system-under-evaluation, + # label_selector: app.kubernetes.io/component, + # label: recommendationservice, + # interval: 15s, + # } + #reloading the prometheus config is not as trivial as it seems to be + #- prometheus_scrape_interval: + # action: kubernetes_prometheus_interval + # params: { + # interval: 5s, + # evaluation_interval: 5s, + # scrape_timeout: 3s, + # } + sue: + compose: opentelemetry-demo/docker-compose.yml + exclude: [loadgenerator] + required: [ + { + namespace: system-under-evaluation, + name: astronomy-shop-prometheus-server, + }, + ] # {namespace: monitoring, name: not-running-service} + #required: [{namespace: monitoring, name: grafana}, {namespace: monitoring, name: node-exporter}] + loadgen: + run_time: 5m + max_users: 50 + spawn_rate: 10 + locust_files: + - "/opt/oxn/locust/locust_basic_interaction.py" + - "/opt/oxn/locust/locust_otel_demo.py" + target: + name: astronomy-shop-frontendproxy + namespace: system-under-evaluation + port: 8080 diff --git a/experiments/short.yml b/experiments/short.yml new file mode 100644 index 0000000..ff40bc9 --- /dev/null +++ b/experiments/short.yml @@ -0,0 +1,180 @@ +# yaml-language-server: $schema=experiment_schema.json +experiment: + name: k8s-test-successful + version: 0.0.1 + orchestrator: kubernetes + services: + jaeger: + name: astronomy-shop-jaeger-query + namespace: system-under-evaluation + prometheus: + [ + { + name: astronomy-shop-prometheus-server, + namespace: system-under-evaluation, + target: sue, + }, + { + name: kube-prometheus-kube-prome-prometheus, + namespace: oxn-external-monitoring, + target: oxn, + }, + ] + responses: + - name: frontend_traces + type: trace + service_name: frontend + left_window: 10s + right_window: 10s + limit: 1 + - name: system_CPU + type: metric + metric_name: sum(rate(container_cpu_usage_seconds_total{namespace="system-under-evaluation"}[1m])) + left_window: 10s + right_window: 10s + step: 1 + target: oxn + - name: recommendation_deployment_CPU + type: metric + metric_name: sum(rate(container_cpu_usage_seconds_total{namespace="system-under-evaluation", pod=~"astronomy-shop-recommendationservice.*"}[90s])) by (pod) + left_window: 10s + right_window: 10s + step: 1 + target: oxn + - name: frontend_http_latency + type: metric + metric_name: histogram_quantile(0.95, sum(rate(http_server_duration_milliseconds_bucket{job="opentelemetry-demo/frontend"}[90s])) by (http_method, http_status_code, le)) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: cart_service_latency + type: metric + metric_name: histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket{job="opentelemetry-demo/cartservice"}[90s])) by (http_route, le)) * 1000 + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: product_catalog_latency + type: metric + metric_name: histogram_quantile(0.95, sum(rate(rpc_server_duration_milliseconds_bucket{job="opentelemetry-demo/productcatalogservice"}[90s])) by (rpc_method, le)) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: pod_status + type: metric + metric_name: sum by (phase) (kube_pod_status_phase{namespace="system-under-evaluation"}) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: pod_restarts + type: metric + metric_name: sum(kube_pod_container_status_restarts_total{namespace="system-under-evaluation"}) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: terminated_pods + type: metric + metric_name: sum(kube_pod_container_status_terminated{namespace="system-under-evaluation"}) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: failed_spans + type: metric + metric_name: sum(rate(otelcol_exporter_send_failed_spans[1m])) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: network_bytes + type: metric + metric_name: sum(rate(node_network_receive_bytes_total[1m]) + rate(node_network_transmit_bytes_total[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: network_drops + type: metric + metric_name: sum(rate(node_network_receive_drop_total[1m]) + rate(node_network_transmit_drop_total[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: network_errors + type: metric + metric_name: sum(rate(node_network_receive_errs_total[1m]) + rate(node_network_transmit_errs_total[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: tcp_retransmissions + type: metric + metric_name: sum(rate(node_netstat_Tcp_RetransSegs[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: tcp_udp_errors + type: metric + metric_name: sum(rate(node_netstat_Tcp_InErrs[1m]) + rate(node_netstat_Udp_InErrors[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: node_load + type: metric + metric_name: node_load1 + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: memory_available + type: metric + metric_name: node_memory_MemAvailable_bytes + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: cpu_usage + type: metric + metric_name: sum(rate(node_cpu_seconds_total{mode!="idle"}[1m])) by (instance) + left_window: 10s + right_window: 10s + step: 1 + target: sue + - name: tcp_connections + type: metric + metric_name: node_netstat_Tcp_CurrEstab + left_window: 10s + right_window: 10s + step: 1 + target: sue + treatments: + - empty_treatment: + action: empty + params: { duration: 1m } + sue: + compose: opentelemetry-demo/docker-compose.yml + exclude: [loadgenerator] + required: + [ + { + namespace: system-under-evaluation, + name: astronomy-shop-prometheus-server, + }, + ] + loadgen: + run_time: 2m + max_users: 10 + spawn_rate: 5 + locust_files: + - "/opt/oxn/locust/locust_basic_interaction.py" + - "/opt/oxn/locust/locust_otel_demo.py" + target: + name: astronomy-shop-frontendproxy + namespace: system-under-evaluation + port: 8080 diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d7bd896 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,66 @@ +# syntax=docker.io/docker/dockerfile:1 + +FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..6a31951 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,15 @@ +## Getting Started + +First, install dependencies: + +```bash +npm install +``` + +Then, run the development server: + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. diff --git a/frontend/app/error.tsx b/frontend/app/error.tsx new file mode 100644 index 0000000..5521b61 --- /dev/null +++ b/frontend/app/error.tsx @@ -0,0 +1,18 @@ +'use client' +import { Button } from "@/components/ui/button"; + +export default function ErrorPage({ error, reset }: { error: Error; reset: () => void }) { + + return ( +
+

Something went wrong

+

{error?.message || 'An unexpected error has occurred.'}

+ +
+ ) +} \ No newline at end of file diff --git a/frontend/app/experiment-setup/page.tsx b/frontend/app/experiment-setup/page.tsx new file mode 100644 index 0000000..4f071eb --- /dev/null +++ b/frontend/app/experiment-setup/page.tsx @@ -0,0 +1,8 @@ +export default function ExperimentSetupPage() { + + return ( +
+ All the experiments setups will be included here... +
+ ) +} \ No newline at end of file diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/app/favicon.ico differ diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..57e6550 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { ThemeProvider } from "@/context/theme-provider"; +import "@/styles/globals.css" +import Layout from "@/components/layout"; + +export const metadata: Metadata = { + title: "OXN++ Dashboard", + description: "A user-friendly interface for configuring, monitoring, and analyzing observability experiments in cloud-native applications.", + keywords: ["OXN", "Observability", "Microservices", "Dashboard", "Cloud-Native", "Fault Injection", "Performance Monitoring"], + authors: [], + applicationName: "OXN++", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + {children} + + + + + ); +} diff --git a/frontend/app/not-found.tsx b/frontend/app/not-found.tsx new file mode 100644 index 0000000..1ac0ca0 --- /dev/null +++ b/frontend/app/not-found.tsx @@ -0,0 +1,16 @@ +import { Button } from "@/components/ui/button"; +import Link from "next/link"; + +export default function NotFound() { + return ( +
+

404 - Page Not Found

+

The page you are looking for does not exist or has been moved.

+ + + +
+ ); +} \ No newline at end of file diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..6a6591c --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,51 @@ +'use client' +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +export default function Home() { + + return ( +
+ + + + + + + Start new experiment + + Upload a YAML file with experiment configurations. + + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ ); +} diff --git a/frontend/app/public/READ.MD b/frontend/app/public/READ.MD new file mode 100644 index 0000000..bc62aa5 --- /dev/null +++ b/frontend/app/public/READ.MD @@ -0,0 +1 @@ +This dir is for all static assets used by the frontend. \ No newline at end of file diff --git a/frontend/app/real-time/page.tsx b/frontend/app/real-time/page.tsx new file mode 100644 index 0000000..d994322 --- /dev/null +++ b/frontend/app/real-time/page.tsx @@ -0,0 +1,8 @@ +export default function RealTimeMonitoring() { + + return ( +
+ Real time monitoring coming soon.... +
+ ) +} \ No newline at end of file diff --git a/frontend/app/results-and-reports/page.tsx b/frontend/app/results-and-reports/page.tsx new file mode 100644 index 0000000..583118d --- /dev/null +++ b/frontend/app/results-and-reports/page.tsx @@ -0,0 +1,8 @@ +export default function ResultsAndReportsPage() { + + return ( +
+ All the reports and results will be included here... +
+ ) +} \ No newline at end of file diff --git a/frontend/app/search/page.tsx b/frontend/app/search/page.tsx new file mode 100644 index 0000000..629713e --- /dev/null +++ b/frontend/app/search/page.tsx @@ -0,0 +1,8 @@ +export default function SearchPage() { + + return ( +
+ Global search page... +
+ ) +} \ No newline at end of file diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx new file mode 100644 index 0000000..15aa2ef --- /dev/null +++ b/frontend/app/settings/page.tsx @@ -0,0 +1,8 @@ +export default function SettingsPage() { + + return ( +
+ Global settings page... +
+ ) +} \ No newline at end of file diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..c8c85e6 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "styles/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx new file mode 100644 index 0000000..bc4bcb7 --- /dev/null +++ b/frontend/components/app-sidebar.tsx @@ -0,0 +1,44 @@ +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" +import { items } from "@/configurations/menu" + +export function AppSidebar() { + + return ( + + + + + + MAIN + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + + + + + ) +} diff --git a/frontend/components/dark-mode-toggle.tsx b/frontend/components/dark-mode-toggle.tsx new file mode 100644 index 0000000..a92b982 --- /dev/null +++ b/frontend/components/dark-mode-toggle.tsx @@ -0,0 +1,42 @@ +"use client" + +import * as React from "react" +import { Moon, Sun } from "lucide-react" +import { useTheme } from "next-themes" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +export function ThemeToggle() { + const { setTheme } = useTheme() + + return ( +
+ + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + +
+ ) +} diff --git a/frontend/components/layout.tsx b/frontend/components/layout.tsx new file mode 100644 index 0000000..b62e37d --- /dev/null +++ b/frontend/components/layout.tsx @@ -0,0 +1,20 @@ +import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" +import { AppSidebar } from "./app-sidebar" +import { ThemeToggle } from "./dark-mode-toggle" +export default function Layout({ children }: { children: React.ReactNode }) { + + return ( + + +
+
+ + +
+
+ {children} +
+
+
+ ) +} diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx new file mode 100644 index 0000000..65d4fcd --- /dev/null +++ b/frontend/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx new file mode 100644 index 0000000..1647513 --- /dev/null +++ b/frontend/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/frontend/components/ui/dropdown-menu.tsx b/frontend/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..082639f --- /dev/null +++ b/frontend/components/ui/dropdown-menu.tsx @@ -0,0 +1,201 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx new file mode 100644 index 0000000..69b64fb --- /dev/null +++ b/frontend/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/components/ui/label.tsx b/frontend/components/ui/label.tsx new file mode 100644 index 0000000..5341821 --- /dev/null +++ b/frontend/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/frontend/components/ui/separator.tsx b/frontend/components/ui/separator.tsx new file mode 100644 index 0000000..12d81c4 --- /dev/null +++ b/frontend/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/frontend/components/ui/sheet.tsx b/frontend/components/ui/sheet.tsx new file mode 100644 index 0000000..272cb72 --- /dev/null +++ b/frontend/components/ui/sheet.tsx @@ -0,0 +1,140 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/frontend/components/ui/sidebar.tsx b/frontend/components/ui/sidebar.tsx new file mode 100644 index 0000000..eeb2d7a --- /dev/null +++ b/frontend/components/ui/sidebar.tsx @@ -0,0 +1,763 @@ +"use client" + +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { VariantProps, cva } from "class-variance-authority" +import { PanelLeft } from "lucide-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { Sheet, SheetContent } from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar:state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContext = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) + } +) +SidebarProvider.displayName = "SidebarProvider" + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) + } +) +Sidebar.displayName = "Sidebar" + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + + ) +}) +SidebarTrigger.displayName = "SidebarTrigger" + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( +