This commit is contained in:
Celina Lopez
2024-09-25 14:34:26 -07:00
parent e59ed8250a
commit 520242f7fd
121 changed files with 2772 additions and 4 deletions

View File

@@ -84,3 +84,5 @@ gem "rqrcode", "~> 2.2"
gem "oj", "~> 3.16"
gem "omniauth", "~> 2.1"
gem "omniauth-rails_csrf_protection", "~> 1.0"
gem "annotate", "~> 3.2"

View File

@@ -89,6 +89,9 @@ GEM
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.2)
base64 (0.2.0)
bcrypt (3.1.20)
@@ -503,6 +506,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
annotate (~> 3.2)
bootsnap
brakeman
capybara

View File

@@ -0,0 +1,98 @@
class AddOnsController < ApplicationController
include StorageHelper
before_action :set_add_on, only: [:show, :edit, :update, :destroy, :logs]
# GET /add_ons
def index
@pagy, @add_ons = pagy(AddOn.sort_by_params(params[:sort], sort_direction))
# Uncomment to authorize with Pundit
# authorize @add_ons
end
# GET /add_ons/1 or /add_ons/1.json
def show
end
# GET /add_ons/new
def new
@add_on = AddOn.new
# Uncomment to authorize with Pundit
# authorize @add_on
end
def logs
end
# GET /add_ons/1/edit
def edit
end
# POST /add_ons or /add_ons.json
def create
@add_on = AddOn.new(add_on_params)
# Uncomment to authorize with Pundit
# authorize @add_on
respond_to do |format|
if @add_on.save
#AddOns::InstallJob.perform_later(@add_on)
format.html { redirect_to @add_on, notice: "Add on was successfully created." }
format.json { render :show, status: :created, location: @add_on }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @add_on.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /add_ons/1 or /add_ons/1.json
def update
respond_to do |format|
if @add_on.update(add_on_params)
format.html { redirect_to @add_on, notice: "Add on was successfully updated." }
format.json { render :show, status: :ok, location: @add_on }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @add_on.errors, status: :unprocessable_entity }
end
end
end
# DELETE /add_ons/1 or /add_ons/1.json
def destroy
@add_on.uninstalling!
respond_to do |format|
AddOns::UninstallJob.perform_later(@add_on)
format.html { redirect_to add_ons_url, status: :see_other, notice: "Uninstalling add on #{@add_on.name}" }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_add_on
@add_on = current_user.add_ons.find(params[:id])
# Uncomment to authorize with Pundit
# authorize @add_on
if @add_on.chart_type == "redis"
@service = K8::Helm::Redis.new(@add_on)
elsif @add_on.chart_type == "postgresql"
@service = K8::Helm::Postgresql.new(@add_on)
end
rescue ActiveRecord::RecordNotFound
redirect_to add_ons_path
end
# Only allow a list of trusted parameters through.
def add_on_params
params[:add_on][:metadata] = params[:add_on][:metadata][params[:add_on][:chart_type]]
params.require(:add_on).permit(:cluster_id, :chart_type, :name, metadata: {})
# Uncomment to use Pundit permitted attributes
# params.require(:add_on).permit(policy(@add_on).permitted_attributes)
end
end

View File

@@ -0,0 +1,9 @@
class Clusters::BaseController < ApplicationController
before_action :set_cluster
private
def set_cluster
@cluster = Cluster.find(params[:cluster_id])
end
end

View File

@@ -0,0 +1,8 @@
class Clusters::MetricsController < Clusters::BaseController
before_action :set_cluster
def show
@pod_metrics = K8::Metrics::Pods.call(@cluster)
@node_metrics = K8::Metrics::Nodes.call(@cluster)
end
end

View File

@@ -0,0 +1,110 @@
class ClustersController < ApplicationController
before_action :set_cluster, only: [:show, :edit, :update, :destroy, :test_connection, :download_kubeconfig]
# GET /clusters
def index
@pagy, @clusters = pagy(current_user.clusters.sort_by_params(params[:sort], sort_direction))
# Uncomment to authorize with Pundit
# authorize @clusters
end
# GET /clusters/1 or /clusters/1.json
def show
end
# GET /clusters/new
def new
@cluster = Cluster.new
# Uncomment to authorize with Pundit
# authorize @cluster
end
# GET /clusters/1/edit
def edit
end
def restart
K8::Kubectl.new(@cluster.kubeconfig).run("rollout restart deployment")
redirect_to @cluster, notice: "Cluster was successfully restarted."
end
def test_connection
client = K8::Client.new(@cluster.kubeconfig)
if client.can_connect?
render turbo_stream: turbo_stream.replace("test_connection_frame", partial: "clusters/connection_success")
else
render turbo_stream: turbo_stream.replace("test_connection_frame", partial: "clusters/connection_failed")
end
end
def download_kubeconfig
send_data @cluster.kubeconfig, filename: "#{@cluster.name}-kubeconfig.yml", type: "application/yaml"
end
# POST /clusters or /clusters.json
def create
@cluster = current_user.clusters.new(cluster_params)
# Uncomment to authorize with Pundit
# authorize @cluster
respond_to do |format|
if @cluster.save
# Kick off cluster job
InstallClusterJob.perform_later(@cluster)
format.html { redirect_to @cluster, notice: "Cluster was successfully created." }
format.json { render :show, status: :created, location: @cluster }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @cluster.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /clusters/1 or /clusters/1.json
def update
respond_to do |format|
if @cluster.update(cluster_params)
format.html { redirect_to @cluster, notice: "Cluster was successfully updated." }
format.json { render :show, status: :ok, location: @cluster }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @cluster.errors, status: :unprocessable_entity }
end
end
end
# DELETE /clusters/1 or /clusters/1.json
def destroy
@cluster.destroy!
respond_to do |format|
format.html { redirect_to clusters_url, status: :see_other, notice: "Cluster was successfully destroyed." }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_cluster
@cluster = current_user.clusters.find(params[:id])
# Uncomment to authorize with Pundit
# authorize @cluster
rescue ActiveRecord::RecordNotFound
redirect_to clusters_path
end
# Only allow a list of trusted parameters through.
def cluster_params
if params[:cluster][:kubeconfig].present?
kubeconfig_file = params[:cluster][:kubeconfig]
yaml_content = kubeconfig_file.read
params[:cluster][:kubeconfig] = YAML.safe_load(yaml_content).to_json
end
params.require(:cluster).permit(:name, :kubeconfig)
end
end

View File

@@ -0,0 +1,9 @@
class Projects::BaseController < ApplicationController
include ProjectsHelper
before_action :set_project
private
def set_project
@project = current_user.projects.find(params[:project_id])
end
end

View File

@@ -0,0 +1,35 @@
class Projects::DeploymentsController < Projects::BaseController
before_action :set_project
before_action :set_build, only: %i[show redeploy]
def index
@pagy, @builds = pagy(@project.builds.sort_by_params(params[:sort], 'desc'))
end
def show; end
def redeploy
new_build = @build.dup
if new_build.save
Projects::BuildJob.perform_later(new_build)
redirect_to project_deployment_path(@project, new_build), notice: 'Redeploying...'
else
redirect_to project_deployments_url(@project), alert: 'Failed to redeploy'
end
end
def deploy
result = Projects::DeployLatestCommit.execute(project: @project)
if result.success?
redirect_to @project, notice: 'Deploying project...'
else
redirect_to @project, alert: 'Failed to deploy project'
end
end
private
def set_build
@build = @project.builds.find(params[:id])
end
end

View File

@@ -0,0 +1,34 @@
# frozen_string_literal: true
class Projects::DomainsController < Projects::BaseController
before_action :set_project
def create
# TODO(chris): This is a bit of a hack, we should probably refactor this
@domain = @project.services.web_service.first.domains.new(domain_params)
respond_to do |format|
if @domain.save
Projects::AddDomainJob.perform_later(@domain.cluster)
format.html { redirect_to project_path(@project), notice: 'Domain was successfully added.' }
format.json { render :show, status: :created, domain: @domain }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @domain.errors, status: :unprocessable_entity }
end
format.turbo_stream
end
end
def destroy
@domain = @project.domains.find(params[:id])
@domain.destroy
respond_to(&:turbo_stream)
end
private
def domain_params
params.require(:domain).permit(:domain_name)
end
end

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
class Projects::EnvironmentVariablesController < Projects::BaseController
before_action :set_project
def index
@environment_variables = @project.environment_variables
end
def create
EnvironmentVariables::BulkUpdate.execute(project: @project, params:)
if @project.current_deployment.present?
Projects::DeploymentJob.perform_later(@project.current_deployment)
redirect_to project_environment_variables_path(@project),
notice: 'Deployment started to apply new environment variables.'
else
redirect_to project_environment_variables_path(@project),
notice: 'Environment variables will be applied on the next deployment.'
end
end
private
def environment_variable_params
params.require(:environment_variable).permit(:name, :value)
end
end

View File

@@ -0,0 +1,13 @@
class Projects::MetricsController < Projects::BaseController
def index
client = K8::Client.from_project(@project).client
@services = client.get_services
@service = @services.find { |service| service.metadata.name == "#{@project.name}-service" }
@pod_metrics = if @service.present?
selector = "app=#{@service.metadata['labels'].app}"
K8::Metrics::Pods.call(@project.cluster, selector:)
else
[]
end
end
end

View File

@@ -0,0 +1,46 @@
class Projects::ProjectAddOnsController < Projects::BaseController
before_action :set_project_add_on, only: %i[show edit update destroy]
def index
@project_add_ons = @project.project_add_ons
end
def new
@project_add_on = @project.project_add_ons.build
end
def create
@project_add_on = @project.project_add_ons.build(project_add_on_params)
if @project_add_on.save
redirect_to project_project_add_on_path(@project, @project_add_on),
notice: 'Project add-on was successfully created.'
else
render :new
end
end
def update
if @project_add_on.update(project_add_on_params)
redirect_to project_project_add_on_path(@project, @project_add_on),
notice: 'Project add-on was successfully updated.'
else
render :edit
end
end
def destroy
@project_add_on.destroy
redirect_to project_project_add_ons_path(@project), notice: 'Project add-on was successfully destroyed.'
end
private
def set_project_add_on
@project_add_on = @project.project_add_ons.find(params[:id])
end
def project_add_on_params
params.require(:project_add_on).permit(:project_id, :add_on_id)
end
end

View File

@@ -0,0 +1,47 @@
class Projects::ServicesController < Projects::BaseController
before_action :set_project
before_action :set_service, only: %i[update destroy]
def index
@services = @project.services
end
def new
@service = @project.services.build
end
def create
service = @project.services.build(service_params)
if service.save
redirect_to project_services_path(@project), notice: 'Service was successfully created.'
else
redirect_to project_services_path(@project), alert: 'Service could not be created.'
end
end
def update
if @service.update(service_params)
redirect_to project_services_path(@project), notice: 'Service was successfully updated.'
else
redirect_to project_services_path(@project), alert: 'Service could not be updated.'
end
end
def destroy
if @service.destroy
redirect_to project_services_path(@project), notice: 'Service was successfully destroyed.'
else
redirect_to project_services_path(@project), alert: 'Service could not be destroyed.'
end
end
private
def set_service
@service = @project.services.find(params[:id])
end
def service_params
params.require(:service).permit(:service_type, :command, :name)
end
end

View File

@@ -0,0 +1,89 @@
class ProjectsController < ApplicationController
include ProjectsHelper
before_action :set_project, only: %i[show edit update destroy]
# GET /projects
def index
@pagy, @projects = pagy(current_user.projects.sort_by_params(params[:sort], sort_direction))
# Uncomment to authorize with Pundit
# authorize @projects
end
# GET /projects/1 or /projects/1.json
def show; end
# GET /projects/new
def new
@project = Project.new
# Uncomment to authorize with Pundit
# authorize @project
end
# GET /projects/1/edit
def edit; end
# POST /projects or /projects.json
def create
result = Projects::Create.call(Project.new(project_params), params)
@project = result.project
respond_to do |format|
if result.success?
format.html { redirect_to @project, notice: 'Project was successfully created.' }
format.json { render :show, status: :created, location: @project }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @project.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /projects/1 or /projects/1.json
def update
respond_to do |format|
if @project.update(project_params)
format.html { redirect_to @project, notice: 'Project was successfully updated.' }
format.json { render :show, status: :ok, location: @project }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @project.errors, status: :unprocessable_entity }
end
end
end
# DELETE /projects/1 or /projects/1.json
def destroy
@project.destroy!
respond_to do |format|
format.html { redirect_to projects_url, status: :see_other, notice: 'Project was successfully destroyed.' }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_project
@project = Project.find(params[:id])
# Uncomment to authorize with Pundit
# authorize @project
rescue ActiveRecord::RecordNotFound
redirect_to projects_path
end
# Only allow a list of trusted parameters through.
def project_params
params.require(:project).permit(
:name,
:repository_url,
:branch,
:cluster_id,
:docker_build_context_directory,
:docker_command,
:dockerfile_path
)
end
end

24
app/models/add_on.rb Normal file
View File

@@ -0,0 +1,24 @@
# == Schema Information
#
# Table name: add_ons
#
# id :bigint not null, primary key
# chart_type :string not null
# metadata :jsonb
# name :string not null
# status :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# cluster_id :bigint not null
#
# Indexes
#
# index_add_ons_on_cluster_id (cluster_id)
# index_add_ons_on_cluster_id_and_name (cluster_id,name) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (cluster_id => clusters.id)
#
class AddOn < ApplicationRecord
end

View File

@@ -1,3 +1,15 @@
# == Schema Information
#
# Table name: announcements
#
# id :bigint not null, primary key
# announcement_type :string
# description :text
# name :string
# published_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
#
class Announcement < ApplicationRecord
TYPES = %w{ new fix update }

24
app/models/build.rb Normal file
View File

@@ -0,0 +1,24 @@
# == Schema Information
#
# Table name: builds
#
# id :bigint not null, primary key
# commit_message :string
# commit_sha :string not null
# git_sha :string
# repository_url :string
# status :integer default(0)
# created_at :datetime not null
# updated_at :datetime not null
# project_id :bigint not null
#
# Indexes
#
# index_builds_on_project_id (project_id)
#
# Foreign Keys
#
# fk_rails_... (project_id => projects.id)
#
class Build < ApplicationRecord
end

22
app/models/cluster.rb Normal file
View File

@@ -0,0 +1,22 @@
# == Schema Information
#
# Table name: clusters
#
# id :bigint not null, primary key
# kubeconfig :jsonb not null
# name :string not null
# status :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_clusters_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class Cluster < ApplicationRecord
end

View File

@@ -0,0 +1,18 @@
# == Schema Information
#
# Table name: cron_schedules
#
# id :bigint not null, primary key
# schedule :string not null
# service_id :bigint not null
#
# Indexes
#
# index_cron_schedules_on_service_id (service_id)
#
# Foreign Keys
#
# fk_rails_... (service_id => services.id)
#
class CronSchedule < ApplicationRecord
end

20
app/models/deployment.rb Normal file
View File

@@ -0,0 +1,20 @@
# == Schema Information
#
# Table name: deployments
#
# id :bigint not null, primary key
# status :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# build_id :bigint not null
#
# Indexes
#
# index_deployments_on_build_id (build_id)
#
# Foreign Keys
#
# fk_rails_... (build_id => builds.id)
#
class Deployment < ApplicationRecord
end

20
app/models/domain.rb Normal file
View File

@@ -0,0 +1,20 @@
# == Schema Information
#
# Table name: domains
#
# id :bigint not null, primary key
# domain_name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# service_id :bigint not null
#
# Indexes
#
# index_domains_on_service_id (service_id)
#
# Foreign Keys
#
# fk_rails_... (service_id => services.id)
#
class Domain < ApplicationRecord
end

View File

@@ -0,0 +1,22 @@
# == Schema Information
#
# Table name: environment_variables
#
# id :bigint not null, primary key
# name :string not null
# value :text
# created_at :datetime not null
# updated_at :datetime not null
# project_id :bigint not null
#
# Indexes
#
# index_environment_variables_on_project_id (project_id)
# index_environment_variables_on_project_id_and_name (project_id,name) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (project_id => projects.id)
#
class EnvironmentVariable < ApplicationRecord
end

13
app/models/log_output.rb Normal file
View File

@@ -0,0 +1,13 @@
# == Schema Information
#
# Table name: log_outputs
#
# id :bigint not null, primary key
# loggable_type :string not null
# output :text
# created_at :datetime not null
# updated_at :datetime not null
# loggable_id :bigint not null
#
class LogOutput < ApplicationRecord
end

29
app/models/project.rb Normal file
View File

@@ -0,0 +1,29 @@
# == Schema Information
#
# Table name: projects
#
# id :bigint not null, primary key
# autodeploy :boolean default(TRUE), not null
# branch :string default("main"), not null
# docker_build_context_directory :string default("."), not null
# docker_command :string
# dockerfile_path :string default("./Dockerfile"), not null
# name :string not null
# predeploy_command :string
# repository_url :string not null
# status :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# cluster_id :bigint not null
#
# Indexes
#
# index_projects_on_cluster_id (cluster_id)
# index_projects_on_name (name) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (cluster_id => clusters.id)
#
class Project < ApplicationRecord
end

View File

@@ -0,0 +1,22 @@
# == Schema Information
#
# Table name: project_add_ons
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# add_on_id :bigint not null
# project_id :bigint not null
#
# Indexes
#
# index_project_add_ons_on_add_on_id (add_on_id)
# index_project_add_ons_on_project_id (project_id)
#
# Foreign Keys
#
# fk_rails_... (add_on_id => add_ons.id)
# fk_rails_... (project_id => projects.id)
#
class ProjectAddOn < ApplicationRecord
end

View File

@@ -1,3 +1,27 @@
# == Schema Information
#
# Table name: services
#
# id :bigint not null, primary key
# access_token :string
# access_token_secret :string
# auth :text
# expires_at :datetime
# provider :string
# refresh_token :string
# uid :string
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_services_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class Service < ApplicationRecord
belongs_to :user

View File

@@ -1,3 +1,25 @@
# == Schema Information
#
# Table name: users
#
# id :bigint not null, primary key
# admin :boolean default(FALSE)
# announcements_last_read_at :datetime
# email :string default(""), not null
# encrypted_password :string default(""), not null
# first_name :string
# last_name :string
# remember_created_at :datetime
# reset_password_sent_at :datetime
# reset_password_token :string
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_users_on_email (email) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
#
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable

View File

@@ -0,0 +1,14 @@
<!-- How to represent an add_on on other surfaces -->
<%= link_to add_on_path(add_on) do %>
<div class="card">
<div class="card-body">
<h2 class="card-title">
<%= add_on.name %>
</h2>
<p class="card-text">
<%= render "add_ons/status", add_on: add_on %>
<%= add_on.chart_type.titleize %>
</p>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,2 @@
json.extract! add_on, :id, :cluster_id, :add_on_type, :name, :metadata, :created_at, :updated_at
json.url add_on_url(add_on, format: :json)

View File

@@ -0,0 +1,27 @@
<% chart['template'].each do |variable| %>
<% next if variable['hidden'] %>
<div class="form-group">
<%= form.label variable['name'] %>
<% if variable['type'] == 'string' %>
<%= form.text_field variable['key'], name: "add_on[metadata][#{chart['name']}][template][#{variable['key']}]", class: "input input-bordered" %>
<% elsif variable['type'] == 'size' %>
<%= form.hidden_field(
"metadata][#{chart['name']}][template][#{variable['key']}][type",
value: 'size'
) %>
<%= form.text_field(
variable['key'],
name: "add_on[metadata][#{chart['name']}][template][#{variable['key']}][value]",
class: "input input-bordered",
type: 'number',
value: variable['default'][..-3]
) %>
<%= form.select(
"metadata][#{chart['name']}][template][#{variable['key']}][unit",
options_for_select([['Gi', 'Gi'], ['Mi', 'Mi'], ['Ki', 'Ki']]),
{ value: variable['default'][-2..] },
{ class: "select select-bordered" }
) %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,22 @@
<table class="table w-full">
<thead class="border-b-2 border-gray-300">
<tr>
<th class="text-left py-2">Name</th>
<th class="text-left py-2">Helm Chart</th>
<th class="text-left py-2">Cluster</th>
<th class="text-left py-2">Status</th>
<th class="text-left py-2">Created</th>
</tr>
</thead>
<tbody>
<% add_ons.each do |add_on| %>
<tr class="border-b border-gray-200">
<td class="py-4"><%= link_to add_on.name, add_on, class: "text-inherit hover:underline" %></td>
<td class="py-4"><%= add_on.chart_type %></td>
<td class="py-4"><%= link_to add_on.cluster.name, add_on.cluster, class: "text-inherit hover:underline" %></td>
<td class="py-4"><%= render "add_ons/status", add_on: %></td>
<td class="py-4"><%= time_ago_in_words(add_on.created_at) %> ago</td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -0,0 +1,29 @@
<div class="scene">
<div class="objects">
<div class="square"></div>
<div class="circle"></div>
<div class="triangle"></div>
</div>
<div class="wizard">
<div class="body"></div>
<div class="right-arm">
<div class="right-hand"></div>
</div>
<div class="left-arm">
<div class="left-hand"></div>
</div>
<div class="head">
<div class="beard"></div>
<div class="face">
<div class="adds"></div>
</div>
<div class="hat">
<div class="hat-of-the-hat"></div>
<div class="four-point-star --first"></div>
<div class="four-point-star --second"></div>
<div class="four-point-star --third"></div>
</div>
</div>
</div>
</div>
<div class="progress"></div>

View File

@@ -0,0 +1,35 @@
<% if add_on.installing? %>
<div class="flex flex-col items-center justify-center">
<%= render "add_ons/installing" %>
<div class="mt-4 text-lg">
Your add-on is installing... Go grab a coffee, this could take a while.
</div>
</div>
<% else %>
<div class="container px-4 mx-auto my-8">
<div class="flex items-center justify-between mb-4">
<div>
<div class="text-sm">
<%= render "add_ons/status", add_on: @add_on %>
</div>
<h1 class="h3">
<%= @add_on.name %>
</h1>
<div class="flex flex-row">
<%= link_to @add_on.cluster.name, cluster_path(@add_on.cluster), class: "mr-2" %>
<%= @add_on.chart_type %>
</div>
</div>
</div>
<div class="p-8 dark:border border-base-300 bg-gray-900 rounded-b-box rounded shadow">
<div class="container mx-auto flex">
<!-- Sidebar -->
<%= render "add_ons/sidebar", add_on: add_on %>
<div class="block w-full">
<%= yield %>
</div>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,33 @@
<h4>PostgreSQL</h4>
<div class="mt-2 space-y-6">
<div>
<h6>Storage</h6>
<% @service.storage_metrics.each do |metric| %>
<div>Volume: <pre class="inline"><%= metric[:name] %></pre></div>
<div><strong><%= metric[:usage][:use_percentage] %>%</strong> used out of <strong><%= standardize_size(metric[:usage][:available]) %>B</strong></div>
<progress class="progress w-56" value="<%= metric[:usage][:use_percentage] %>" max="100"></progress>
<% end %>
</div>
<div>
<h6>Created</h6>
<div><%= @add_on.created_at.to_formatted_s(:long_ordinal) %></div>
</div>
<div>
<h6>PostgreSQL Version</h6>
<div><%= @service.version %></div>
</div>
<div>
<h6>Connection Details</h6>
<div class="mt-2">
<label for="redis-password" class="form-label">Connection URL</label>
<div class="flex flex-row" data-controller="toggle-password">
<input type="password" id="redis-password" name="redis-password" class="form-control" value="<%= @service.internal_url %>" data-toggle-password-target="input">
<button class="btn btn-outline-secondary" type="button" data-action="click->toggle-password#toggle">
<i class="bi bi-eye" data-toggle-password-target="icon"></i>
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,33 @@
<h4>Redis</h4>
<div class="mt-2 space-y-6">
<div>
<h6>Storage</h6>
<% @service.storage_metrics.each do |metric| %>
<div>Volume: <pre class="inline"><%= metric[:name] %></pre></div>
<div><strong><%= metric[:usage][:use_percentage] %>%</strong> used out of <strong><%= standardize_size(metric[:usage][:available]) %>B</strong></div>
<progress class="progress w-56" value="<%= metric[:usage][:use_percentage] %>" max="100"></progress>
<% end %>
</div>
<div>
<h6>Created</h6>
<div><%= @add_on.created_at.to_formatted_s(:long_ordinal) %></div>
</div>
<div>
<h6>Redis Version</h6>
<div><%= @service.version %></div>
</div>
<div>
<h6>Connection Details</h6>
<div class="mt-2">
<label for="redis-password" class="form-label">Connection URL</label>
<div class="flex flex-row" data-controller="toggle-password">
<input type="password" id="redis-password" name="redis-password" class="form-control" value="<%= @service.internal_url %>" data-toggle-password-target="input">
<button class="btn btn-outline-secondary" type="button" data-action="click->toggle-password#toggle">
<i class="bi bi-eye" data-toggle-password-target="icon"></i>
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,7 @@
<nav class="w-64 p-4">
<ul class="space-y-2 list-none">
<li><%= link_to "Info", add_on_url(add_on), class: "block py-2 px-4 hover:bg-neutral rounded" %></li>
<li><%= link_to "Logs", logs_add_on_url(add_on), class: "block py-2 px-4 hover:bg-neutral rounded" %></li>
<li><%= link_to "Settings", edit_add_on_url(add_on), class: "block py-2 px-4 hover:bg-neutral rounded" %></li>
</ul>
</nav>

View File

@@ -0,0 +1,10 @@
<% if add_on.installed? %>
<div class="text-green-500">
<%= add_on.status.titleize.upcase %>
</div>
<% elsif %>
<div class="text-danger-500">
<%= add_on.status.titleize.upcase %>
</div>
<% end %>

View File

@@ -0,0 +1,7 @@
<%= content_for :title, t("scaffold.edit.title", model: "Add On") %>
<%= add_on_layout(@add_on) do %>
<div>
Delete Add-on
</div>
<%= button_to "Uninstall", add_on_path(@add_on), method: :delete, class: "btn btn-danger" %>
<% end %>

View File

@@ -0,0 +1,23 @@
<%= content_for :title, "Add Ons" %>
<%= turbo_stream_from :add_ons %>
<div class="container px-4 mx-auto my-8">
<div class="flex items-center justify-between mb-4">
<%= link_to t("scaffold.new.title", model: "Add On"), new_add_on_path, class: "btn btn-secondary" %>
</div>
<%= tag.div id: ("add_ons" if first_page?), class: "bg-white dark:bg-gray-900 dark:border dark:border-gray-700 rounded-md shadow p-6 space-y-8" do %>
<%= render "add_ons/index", add_ons: @add_ons, cached: true %>
<div class="hidden only:block text-center">
<p class="mb-4 h3">Create your first Add On</p>
<%= link_to t("scaffold.new.title", model: "Add On"), new_add_on_path, class: "btn btn-primary" %>
</div>
<% end %>
<% if @pagy.pages > 1 %>
<div class="my-6 text-center">
<%== pagy_nav(@pagy) %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1 @@
json.array! @add_ons, partial: "add_ons/add_on", as: :add_on

View File

@@ -0,0 +1,3 @@
<%= add_on_layout(@add_on) do %>
<%= render "log_outputs/logs", loggable: @add_on %>
<% end %>

View File

@@ -0,0 +1,74 @@
<%= content_for :title, t("scaffold.new.title", model: "Add On") %>
<div class="container px-4 mx-auto my-8">
<div class="flex items-center justify-between mb-4">
<h1 class="h3">
<%= link_to "Add Ons", add_ons_path, class: "text-black dark:text-white" %>
<span class="text-gray-400 font-light mx-2">\</span>
<%= t("scaffold.new.title", model: "Add On") %>
</h1>
</div>
<div class="p-8 bg-white dark:bg-gray-900 dark:border dark:border-gray-700 rounded shadow" data-controller="new-add-ons">
<%= form_with(model: @add_on) do |form| %>
<%= render "error_messages", resource: form.object %>
<div class="form-group">
<%= form.label :name %>
<%= form.text_field :name, class: "form-control", value: RandomNameGenerator.generate_name %>
</div>
<div class="form-group">
<%= form.label :cluster_id %>
<%= form.collection_select :cluster_id, current_user.clusters, :id, :name, {}, { class: "select select-bordered" } %>
</div>
<div class="form-group">
<%= form.text_field :chart_type, class: "hidden", data: { 'new-add-ons-target': "input" } %>
</div>
<h4>Select a chart</h4>
<div class="text-sm text-gray-500">
More charts coming soon.
</div>
<div class="grid grid-cols-8 gap-4 my-4">
<% YAML.load_file(Rails.root.join('resources', 'helm', 'charts.yml'))['helm']['charts'].each do |chart| %>
<div class="card bg-base-100 shadow-xl cursor-pointer group overflow-hidden"
data-new-add-ons-target="card"
data-action="click->new-add-ons#selectChart"
data-chart-name="<%= chart['name'] %>">
<figure class="px-12 pt-12 overflow-visible">
<div class="w-full flex items-center justify-center">
<img
src="<%= chart['logo'] %>" alt="<%= chart['name'] %>"
class="h-[50px] object-contain transition-transform duration-300 group-hover:scale-110"
/>
</div>
</figure>
<div class="card-body items-center text-center">
<%= chart['name'].titleize %>
</div>
</div>
<% end %>
</div>
<% YAML.load_file(Rails.root.join('resources', 'helm', 'charts.yml'))['helm']['charts'].each do |chart| %>
<div id="chart-<%= chart['name'] %>" class="chart-form hidden">
<h4><%= chart['name'].titleize %></h4>
<%= render "add_ons/chart_form", chart: chart, form: form %>
</div>
<% end %>
<div>
<%= form.button button_text(form.send(:submit_default_value)), class: "btn btn-primary" %>
<% if form.object.new_record? %>
<%= link_to t("cancel"), add_ons_path, class: "btn btn-secondary" %>
<% else %>
<%= link_to t("cancel"), add_on_path(@add_on), class: "btn btn-secondary" %>
<% end %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<%= add_on_layout(@add_on) do %>
<%= render "add_ons/#{add_on.chart_type}", service: @service %>
<% end %>

View File

@@ -0,0 +1 @@
json.partial! "add_ons/add_on", add_on: @add_on

View File

@@ -0,0 +1,2 @@
json.extract! cluster, :id, :name, :user_id, :created_at, :updated_at
json.url cluster_url(cluster, format: :json)

View File

@@ -0,0 +1 @@
❌ Connection failed!

View File

@@ -0,0 +1 @@
✅ Connection successful!

View File

@@ -0,0 +1,23 @@
<%= form_with(model: cluster) do |form| %>
<%= render "error_messages", resource: form.object %>
<div class="form-group">
<%= form.label :name %>
<%= form.text_field :name, class: "input input-bordered", value: RandomNameGenerator.generate_name %>
</div>
<div class="form-group">
<%= form.label :kubeconfig %>
<%= form.file_field :kubeconfig, class: "file-input w-full" %>
</div>
<div>
<%= form.button button_text(form.send(:submit_default_value)), class: "btn btn-primary" %>
<% if form.object.new_record? %>
<%= link_to t("cancel"), clusters_path, class: "btn btn-secondary" %>
<% else %>
<%= link_to t("cancel"), cluster_path(@cluster), class: "btn btn-secondary" %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,4 @@
<div>
<h4>Health check: <%= cluster.health %></h4>
<%= render "log_outputs/logs", loggable: cluster %>
</div>

View File

@@ -0,0 +1,18 @@
<table class="table w-full">
<thead class="border-b-2 border-gray-300">
<tr>
<th class="text-left py-2">Name</th>
<th class="text-left py-2">Status</th>
<th class="text-left py-2">Created</th>
</tr>
</thead>
<tbody>
<% clusters.each do |cluster| %>
<tr class="border-b border-gray-200">
<td class="py-4"><%= link_to cluster.name, cluster, class: "text-inherit hover:underline" %></td>
<td class="py-4"><%= cluster.status %></td>
<td class="py-4"><%= time_ago_in_words(cluster.created_at) %> ago</td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -0,0 +1,16 @@
<%= content_for :title, t("scaffold.edit.title", model: "Cluster") %>
<div class="container mx-auto px-4 my-8">
<div class="flex items-center justify-between mb-4">
<h1 class="h3">
<%= link_to "Clusters", clusters_path, class: "text-black dark:text-white" %>
<span class="text-gray-400 font-light mx-2">\</span>
<%= t("scaffold.edit.title", model: "Cluster") %>
</h1>
<%= button_to t("delete"), @cluster, method: :delete, class: "btn btn-secondary", form: { data: { turbo_confirm: t("are_you_sure") } } %>
</div>
<div class="p-8 bg-white dark:bg-gray-900 dark:border dark:border-gray-700 rounded shadow">
<%= render "form", cluster: @cluster %>
</div>
</div>

View File

@@ -0,0 +1,23 @@
<%= content_for :title, "Clusters" %>
<%= turbo_stream_from :clusters %>
<div class="container px-4 mx-auto my-8">
<div class="flex items-center justify-between mb-4">
<%= link_to t("scaffold.new.title", model: "Cluster"), new_cluster_path, class: "btn btn-secondary" %>
</div>
<%= tag.div id: ("clusters" if first_page?), class: "bg-white dark:bg-gray-900 dark:border dark:border-gray-700 rounded-md shadow p-6 space-y-8" do %>
<%= render "clusters/index", clusters: @clusters, cached: true %>
<div class="hidden only:block text-center">
<p class="mb-4 h3">Create your first Cluster</p>
<%= link_to t("scaffold.new.title", model: "Cluster"), new_cluster_path, class: "btn btn-primary" %>
</div>
<% end %>
<% if @pagy.pages > 1 %>
<div class="my-6 text-center">
<%== pagy_nav(@pagy) %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1 @@
json.array! @clusters, partial: "clusters/cluster", as: :cluster

View File

@@ -0,0 +1,8 @@
<div class="container mx-auto px-4 py-8">
Run any kubectl command here
<div data-controller="shell" class="bg-white w-full" data-shell-cluster-id-value="<%= @cluster.id %>">
<div data-shell-target="terminal" class="w-full h-full">
</div>
</div>
</div>

View File

@@ -0,0 +1,62 @@
<div class="container mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-4">
<h1 class="h3">
<%= link_to "Clusters", clusters_path, class: "text-black dark:text-white" %>
<span class="text-gray-400 font-light mx-2">\</span>
<%= @cluster.name %> metrics
</h1>
<button onclick="location.reload()" class="btn btn-primary m-1">
Refresh
</button>
</div>
<h2 class="text-xl font-semibold mb-4">Node Metrics</h2>
<div class="overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr>
<th class="px-4 py-2 text-left">Node Name</th>
<th class="px-4 py-2 text-left">CPU Cores</th>
<th class="px-4 py-2 text-left">CPU %</th>
<th class="px-4 py-2 text-left">Memory Bytes</th>
<th class="px-4 py-2 text-left">Memory %</th>
</tr>
</thead>
<tbody>
<% @node_metrics.each do |metric| %>
<tr class="border-b">
<td class="px-4 py-2"><%= metric[:name] %></td>
<td class="px-4 py-2"><%= metric[:cpu_cores] %></td>
<td class="px-4 py-2"><%= metric[:cpu_percent] %></td>
<td class="px-4 py-2"><%= metric[:memory_bytes] %></td>
<td class="px-4 py-2"><%= metric[:memory_percent] %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<div class="overflow-x-auto mt-4">
<table class="w-full table-auto">
<thead class="">
<tr>
<th class="px-4 py-2 text-left">Pod Name</th>
<th class="px-4 py-2 text-left">CPU</th>
<th class="px-4 py-2 text-left">Memory</th>
</tr>
</thead>
<tbody>
<% @pod_metrics.each do |metric| %>
<tr class="border-b">
<td class="px-4 py-2"><%= metric[:name] %></td>
<td class="px-4 py-2"><%= metric[:cpu] %></td>
<td class="px-4 py-2"><%= metric[:memory] %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,15 @@
<%= content_for :title, t("scaffold.new.title", model: "Cluster") %>
<div class="container px-4 mx-auto my-8">
<div class="flex items-center justify-between mb-4">
<h1 class="h3">
<%= link_to "Clusters", clusters_path, class: "text-black dark:text-white" %>
<span class="text-gray-400 font-light mx-2">\</span>
<%= t("scaffold.new.title", model: "Cluster") %>
</h1>
</div>
<div class="p-8 bg-white dark:bg-gray-900 dark:border dark:border-gray-700 rounded shadow">
<%= render "form", cluster: @cluster %>
</div>
</div>

View File

@@ -0,0 +1,42 @@
<%= content_for :title, "Clusters ##{@cluster.id}" %>
<%= turbo_stream_from @cluster %>
<div class="container px-4 mx-auto my-8">
<div class="flex items-center justify-between mb-4">
<h1 class="h3">
<%= link_to "Clusters", clusters_path, class: "text-black dark:text-white" %>
<span class="text-gray-400 font-light mx-2">\</span>
<%= @cluster.name %>
</h1>
<div class="flex">
<%= turbo_frame_tag "test_connection_frame" do %>
<%= button_to "Test Connection", test_connection_cluster_path(@cluster), class: "btn btn-ghost m-1" %>
<% end %>
</div>
</div>
<div class="p-8 bg-white dark:bg-gray-900 dark:border dark:border-gray-700 rounded shadow">
<div id="<%= dom_id @cluster %>">
<div class="mb-4">
<p class="text-sm font-medium text-gray-500">Name</p>
<%= @cluster.name %>
<%= render "log_outputs/logs", loggable: @cluster %>
</div>
<h4>Projects:</h4>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<% @cluster.projects.each do |project| %>
<%= render project %>
<% end %>
</div>
<h4>Add Ons:</h4>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
<% @cluster.add_ons.each do |add_on| %>
<%= render add_on %>
<% end %>
</div>
</div>
</div>
<%= link_to "Metrics", cluster_metrics_path(@cluster) %>
<%= link_to "Download Kubeconfig File", download_kubeconfig_cluster_path(@cluster) %>
</div>

View File

@@ -0,0 +1 @@
json.partial! "clusters/cluster", cluster: @cluster

View File

@@ -0,0 +1,63 @@
<%= form_with(model: project) do |form| %>
<%= render "error_messages", resource: form.object %>
<div class="form-group">
<%= form.label :name %>
<%= form.text_field :name, class: "input input-bordered w-full", value: RandomNameGenerator.generate_name %>
</div>
<div class="form-group">
<%= form.label :cluster_id %>
<%= form.collection_select :cluster_id, current_user.clusters, :id, :name, {}, { class: "select select-bordered" } %>
</div>
<div data-controller="partial-select">
<div class="mb-4 hidden" data-partial-select-target="toggleable" data-project-type="cron_job">
<%= form.label :cron_schedule %>
<%= form.text_field :cron_schedule, class: "input input-bordered w-full" %>
</div>
</div>
<div class="form-group">
<%= form.label "Repository path *" %>
<div class="flex gap-2">
<%= form.text_field :repository_url, class: "input input-bordered w-full", placeholder: "accountname/repo" %>
<%= render "projects/github/connect" %>
</div>
</div>
<div class="form-group">
<%= form.label :branch %>
<%= form.text_field :branch, class: "input input-bordered w-full" %>
</div>
<div class="form-group">
<%= form.label :autodeploy %>
<%= form.check_box :autodeploy, class: "checkbox" %>
</div>
<div class="form-group">
<%= form.label :dockerfile_path %>
<%= form.text_field :dockerfile_path, class: "input input-bordered w-full" %>
</div>
<div class="form-group">
<%= form.label :docker_command %>
<%= form.text_field :docker_command, class: "input input-bordered w-full" %>
</div>
<div class="form-group">
<%= form.label :predeploy_command %>
<%= form.text_field :predeploy_command, class: "input input-bordered w-full" %>
</div>
<div>
<%= form.button button_text(form.send(:submit_default_value)), class: "btn btn-primary" %>
<% if form.object.new_record? %>
<%= link_to t("cancel"), projects_path, class: "btn btn-secondary" %>
<% else %>
<%= link_to t("cancel"), project_path(@project), class: "btn btn-secondary" %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,20 @@
<table class="table w-full">
<thead class="border-b-2 border-gray-300">
<tr>
<th class="text-left py-2">Name</th>
<th class="text-left py-2">Status</th>
<th class="text-left py-2">Cluster Name</th>
<th class="text-left py-2">Last Deployed At</th>
</tr>
</thead>
<tbody>
<% projects.each do |project| %>
<tr class="border-b border-gray-200">
<td class="py-4"><%= link_to project.name, project, class: "text-inherit hover:underline" %></td>
<td class="py-4"><%= project.status %></td>
<td class="py-4"><%= project.cluster&.name %></td>
<td class="py-4"><%= project.last_deployed_at&.strftime('%Y-%m-%d %H:%M:%S') || "Never" %></td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -0,0 +1,39 @@
<div class="container px-4 mx-auto my-8">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="h3">
<%= project.name %>
</h1>
<% if project.domains.any? %>
<div class="my-3">
<% project.domains.each do |domain| %>
<%= link_to domain.domain_name, "https://#{domain.domain_name}", target: "_blank" %>
<i class="bi bi-box-arrow-up-right"></i>
<% end %>
</div>
<% end %>
<div class="text-sm">
<%= link_to project.full_repository_url, target: "_blank" do %>
<i class="bi bi-github"></i>
<span class="underline"><%= project.repository_url %></span>
<i class="bi bi-git ml-2"></i>
<span class="underline"><%= project.branch %></span>
<% end %>
<span class="ml-6"><i class="bi bi-diagram-3"></i> <%= link_to project.cluster.name, project.cluster, target: "_blank", class: "underline" %></span>
</div>
</div>
<div class="flex">
<%= button_to "Restart", restart_cluster_url(project.cluster), class: "btn btn-ghost m-1", data: { turbo: false, disable_with: "Loading..." } %>
<%= button_to "Deploy", deploy_project_deployments_url(project), class: "btn btn-primary m-1", data: { turbo: false, disable_with: "Loading..." } %>
</div>
</div>
<div class="p-8 dark:border border-base-300 bg-gray-900 rounded-b-box rounded shadow">
<div class="container mx-auto flex">
<!-- Sidebar -->
<%= render "projects/sidebar", project: project %>
<div class="block w-full">
<%= yield %>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,14 @@
<!-- How to represent a project on other surfaces -->
<%= link_to project_path(project) do %>
<div class="card">
<div class="card-body">
<h2 class="card-title">
<%= project.name %>
</h2>
<p class="card-text">
<%= render "projects/status", project: project %>
<%= project.repository_url %>
</p>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,2 @@
json.extract! project, :id, :name, :repository_url, :branch, :cluster_id, :subfolder, :created_at, :updated_at
json.url project_url(project, format: :json)

View File

@@ -0,0 +1,10 @@
<nav class="w-64 p-4">
<ul class="space-y-2 list-none">
<li><%= link_to "Services", project_services_path(project), class: "block py-2 px-4 hover:bg-gray-200 rounded" %></li>
<li><%= link_to "Deployments", project_deployments_path(project), class: "block py-2 px-4 hover:bg-gray-200 rounded" %></li>
<li><%= link_to "Environment Variables", project_environment_variables_path(project), class: "block py-2 px-4 hover:bg-gray-200 rounded" %></li>
<li><%= link_to "Logs", "#", class: "block py-2 px-4 hover:bg-gray-200 rounded" %></li>
<li><%= link_to "Metrics", project_metrics_url(project), class: "block py-2 px-4 hover:bg-gray-200 rounded" %></li>
<li><%= link_to "Settings", edit_project_path(project), class: "block py-2 px-4 hover:bg-gray-200 rounded" %></li>
</ul>
</nav>

View File

@@ -0,0 +1,10 @@
<% if project.deployed? %>
<div class="text-green-500">
<%= project.status.titleize %>
</div>
<% elsif %>
<div class="text-danger-500">
<%= project.status.titleize %>
</div>
<% end %>

View File

@@ -0,0 +1,41 @@
<%= content_for :title, "Projects ##{@project.id}" %>
<%= turbo_stream_from @project %>
<%= project_layout(@project) do %>
<h1 class="text-2xl font-bold mb-6">Deployments</h1>
<% if @builds.empty? %>
<div>
<p class="text-gray-500">No deployments yet</p>
</div>
<% else %>
<ul class="space-y-4">
<% @builds.each do |build| %>
<li class="border rounded-lg p-4">
<div>
<p class="font-semibold"><%= build.commit_message %></p>
<p class="text-sm"><%= build.created_at.strftime("%B %d, %Y %H:%M") %></p>
</div>
<div class="mt-2 text-sm">
Status: <%= build.deployment&.status || build.status %>
</div>
<div class="mt-2 text-sm">
Commit:
<%= link_to build.commit_sha[0..6],
"https://github.com/#{@project.repository_url}/commit/#{build.commit_sha}",
class: "underline",
target: "_blank",
rel: "noopener noreferrer" %>
</div>
<div class="mt-2 text-sm">
<%= button_to "Redeploy", redeploy_project_deployment_path(@project, build), method: :post, class: "btn btn-primary" %>
</div>
<div class="mt-2 text-sm">
<%= link_to "View Logs", project_deployment_path(@project, build), class: "btn btn-primary" %>
</div>
<!-- Add more deployment details as needed -->
</li>
<% end %>
</ul>
<% end %>
<% end %>

View File

@@ -0,0 +1,18 @@
<%= content_for :title, "Projects ##{@project.id}" %>
<%= turbo_stream_from @project %>
<%= project_layout(@project) do %>
<h1 class="text-2xl font-bold mb-6">Deployments</h1>
<h2 class="text-xl font-bold mb-4">Build Logs</h2>
<div class="my-4">
<%= render "log_outputs/logs", loggable: @build %>
</div>
<% if @build.deployment %>
<h2 class="text-xl font-bold mb-4">Release Logs</h2>
<div class="my-4">
<%= render "log_outputs/logs", loggable: @build.deployment %>
</div>
<% end %>
<% end %>

View File

@@ -0,0 +1,16 @@
<%= turbo_frame_tag "domains" do %>
<h4>Custom Domains</h4>
<%= form_with(model: [project, Domain.new], data: { turbo_frame: "domains" }) do |form| %>
<%= render "error_messages", resource: form.object %>
<div class="flex gap-2">
<%= form.text_field :domain_name, class: "form-control" %>
<%= form.submit "Add Domain", class: "btn btn-primary" %>
</div>
<% end %>
<h5 class="my-4">Make sure to configure domains with the following DNS records</h5>
<div id="domain-list">
<%= render partial: "projects/domains/list", locals: { project: project } %>
</div>
<% end %>

View File

@@ -0,0 +1,20 @@
<table class="table">
<thead>
<tr>
<th>DOMAIN</th>
<th>A RECORD</th>
<th></th>
</tr>
</thead>
<tbody>
<% project.domains.each do |domain| %>
<tr id="<%= dom_id(domain) %>">
<td><%= link_to domain.domain_name, "https://#{domain.domain_name}", target: "_blank" %></td>
<td><pre class="code">128.93.3.1</pre></td>
<td>
<%= button_to "Remove", project_domain_path(project, domain), method: :delete, class: "btn btn-sm btn-ghost", data: { turbo_frame: "domains" } %>
</td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -0,0 +1,9 @@
<%= turbo_stream.replace "domain-list" do %>
<%= render partial: "projects/domains/list", locals: { project: @project } %>
<% end %>
<%= turbo_stream.update "#{dom_id(@project)}_domain_form" do %>
<%= form_with url: project_domains_path(@project), method: :post, data: { turbo_frame: "domains" } do |form| %>
<%= form.text_field :domain_name, class: "form-control" %>
<%= form.submit "Add Domain", class: "btn btn-primary" %>
<% end %>
<% end %>

View File

@@ -0,0 +1,5 @@
<%= turbo_stream.remove dom_id(@domain) %>
<%= turbo_stream.update "domain-list" do %>
<%= render partial: "projects/domains/list", locals: { project: @project } %>
<% end %>

View File

@@ -0,0 +1,20 @@
<%= content_for :title, t("scaffold.edit.title", model: "Project") %>
<%= project_layout(@project) do %>
<div class="flex items-center justify-between mb-4">
<h1 class="h3">
<%= link_to "Projects", projects_path, class: "text-black dark:text-white" %>
<span class="text-gray-400 font-light mx-2">\</span>
<%= t("scaffold.edit.title", model: "Project") %>
</h1>
<%= button_to t("delete"), @project, method: :delete, class: "btn btn-secondary", form: { data: { turbo_confirm: t("are_you_sure") } } %>
</div>
<div class="mt-4 p-8 bg-white dark:bg-gray-900 dark:border dark:border-gray-700 rounded shadow">
<%= render "projects/domains/index", project: @project %>
</div>
<div class="p-8 bg-white dark:bg-gray-900 dark:border dark:border-gray-700 rounded shadow">
<%= render "form", project: @project %>
</div>
<% end %>

View File

@@ -0,0 +1,15 @@
<%= project_layout(@project) do %>
<div
class="flex-1 p-4"
data-controller="environment-variables"
data-environment-variables-vars-value="<%= @project.environment_variables.map { |e| { name: e.name, value: e.value } }.to_json %>"
>
<h2 class="text-2xl font-bold mb-4">Environment Variables</h2>
<%= form_with(url: project_environment_variables_path(@project), method: :post, class: "space-y-4") do |form| %>
<div data-environment-variables-target="container"></div>
<button class="btn btn-neutral btn-outline" data-action="environment-variables#add">Add New Environment Variable</button>
<%= form.submit "Save", class: "btn btn-primary" %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,3 @@
<% unless current_user.connected_accounts.find_by_provider(:github).present? %>
<%= button_to "Connect Github", omniauth_authorize_path(:user, :github), class: "btn btn-github", data: { turbo: false, disable_with: "Redirecting..." } %>
<% end %>

View File

@@ -0,0 +1,23 @@
<%= content_for :title, "Projects" %>
<%= turbo_stream_from :projects %>
<div class="container px-4 mx-auto my-8">
<div class="flex items-center justify-between mb-4">
<%= link_to t("scaffold.new.title", model: "Project"), new_project_path, class: "btn btn-secondary" %>
</div>
<%= tag.div id: ("projects" if first_page?), class: "bg-white dark:bg-gray-900 dark:border dark:border-gray-700 rounded-md shadow p-6 space-y-8" do %>
<%= render "projects/index", projects: @projects, cached: true %>
<div class="hidden only:block text-center">
<p class="mb-4 h3">Create your first Project</p>
<%= link_to t("scaffold.new.title", model: "Project"), new_project_path, class: "btn btn-primary" %>
</div>
<% end %>
<% if @pagy.pages > 1 %>
<div class="my-6 text-center">
<%== pagy_nav(@pagy) %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1 @@
json.array! @projects, partial: "projects/project", as: :project

View File

@@ -0,0 +1,33 @@
<%= content_for :title, t("scaffold.edit.title", model: "Project") %>
<%= project_layout(@project) do %>
<div class="flex items-center justify-between mb-4">
Metrics
</div>
<h5>Pods</h5>
<div class="overflow-x-auto">
<% if @pod_metrics.present? %>
<table class="w-full table-auto">
<thead class="">
<tr>
<th class="px-4 py-2 text-left text-gray-600">Pod Name</th>
<th class="px-4 py-2 text-left text-gray-600">CPU</th>
<th class="px-4 py-2 text-left text-gray-600">Memory</th>
</tr>
</thead>
<tbody>
<% @pod_metrics.each do |metric| %>
<tr class="border-b">
<td class="px-4 py-2"><%= metric[:name] %></td>
<td class="px-4 py-2"><%= metric[:cpu] %></td>
<td class="px-4 py-2"><%= metric[:memory] %></td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<div class="text-gray-600">No metrics available</div>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,15 @@
<%= content_for :title, t("scaffold.new.title", model: "Project") %>
<div class="container px-4 mx-auto my-8">
<div class="flex items-center justify-between mb-4">
<h1 class="h3">
<%= link_to "Projects", projects_path, class: "text-black dark:text-white" %>
<span class="text-gray-400 font-light mx-2">\</span>
<%= t("scaffold.new.title", model: "Project") %>
</h1>
</div>
<div class="p-8 bg-white dark:bg-gray-900 dark:border dark:border-gray-700 rounded shadow">
<%= render "form", project: @project %>
</div>
</div>

View File

@@ -0,0 +1,4 @@
<span class="text-xs font-semibold">
<i class="bi bi-globe"></i>
<%= project.project_type.titleize.upcase %>
</span>

View File

@@ -0,0 +1,31 @@
<%= project_layout(@project) do %>
<div class="flex items-center justify-between mb-4">
Services
</div>
<div>
<%= turbo_frame_tag "new_service" do %>
<%= link_to "New Service", new_project_service_path(@project), class: "btn btn-primary" %>
<% end %>
</div>
<div class="overflow-x-auto">
<table class="w-full table-auto">
<thead class="">
<tr>
<th class="px-4 py-2 text-left">Name</th>
<th class="px-4 py-2 text-left">Service Type</th>
<th class="px-4 py-2 text-left">Command</th>
</tr>
</thead>
<tbody>
<% @project.services.each do |service| %>
<tr class="border-b">
<td class="px-4 py-2"><%= service.name %></td>
<td class="px-4 py-2"><%= service.service_type.titleize %></td>
<td class="px-4 py-2"><%= service.command %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>

View File

@@ -0,0 +1,45 @@
<%= turbo_frame_tag "new_service" do %>
<div class="overflow-x-auto">
<%= form_with(model: [@project, @service]) do |form| %>
<%= form.label :name %>
<%= form.text_field :name, class: "input input-bordered", required: true %>
<div data-controller="partial-select">
<%= form.label :service_type %>
<%= form.select(
:service_type,
Service.service_types.keys.map { |type| [type.titleize, type] },
{},
class: "select select-bordered",
data: {
"partial-select-target": "select",
"select-attribute-value": "data-project-type",
"action": "change->partial-select#toggle",
}
) %>
</div>
<%= form.label :command %>
<%= form.text_field :command, class: "input input-bordered", required: false %>
<%= form.button button_text(form.send(:submit_default_value)), class: "btn btn-primary" %>
<% end %>
<div class="text-sm mb-4">
<p>
<b>Web service</b>: Useful for publicly accessible services like web applications.
</p>
<p>
<b>Internal service</b>: Useful for services that should only be connected to within the cluster like microservices.
</p>
<p>
<b>Process</b>: Useful for services that should don't need to be connected to like background workers that are continuously running.
</p>
<p>
<b>Cron job</b>: Useful for running tasks at a specific time like backups.
</p>
<div class="mt-4 text-warning italic">
Do not create any databases here, create an <%= link_to "add on", add_ons_path %> and then connect it to your project.
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,6 @@
<%= content_for :title, "Projects ##{@project.id}" %>
<%= turbo_stream_from @project %>
<%= project_layout(@project) do %>
<%= render @project %>
<% end %>

View File

@@ -0,0 +1 @@
json.partial! "projects/project", project: @project

View File

@@ -0,0 +1,12 @@
class CreateClusters < ActiveRecord::Migration[7.2]
def change
create_table :clusters do |t|
t.string :name, null: false
t.jsonb :kubeconfig, null: false, default: {}
t.references :user, null: false, foreign_key: true
t.integer :status, null: false, default: 0
t.timestamps
end
end
end

View File

@@ -0,0 +1,19 @@
class CreateProjects < ActiveRecord::Migration[7.2]
def change
create_table :projects do |t|
t.string :name, null: false
t.string :repository_url, null: false
t.string :branch, default: "main", null: false
t.references :cluster, null: false, foreign_key: true
t.boolean :autodeploy, default: true, null: false
t.string :dockerfile_path, default: "./Dockerfile", null: false
t.string :docker_build_context_directory, default: ".", null: false
t.string :docker_command
t.string :predeploy_command
t.integer :status, default: 0, null: false
t.timestamps
end
add_index :projects, [ :name ], unique: true
end
end

View File

@@ -0,0 +1,12 @@
class CreateEnvironmentVariables < ActiveRecord::Migration[7.2]
def change
create_table :environment_variables do |t|
t.string :name, null: false
t.text :value
t.references :project, null: false, foreign_key: true
t.timestamps
end
add_index :environment_variables, [ :project_id, :name ], unique: true
end
end

View File

@@ -0,0 +1,14 @@
class CreateBuilds < ActiveRecord::Migration[7.2]
def change
create_table :builds do |t|
t.references :project, null: false, foreign_key: true
t.string :repository_url
t.string :git_sha
t.string :commit_message
t.integer :status, default: 0
t.string :commit_sha, null: false
t.timestamps
end
end
end

View File

@@ -0,0 +1,11 @@
class CreateLogOutputs < ActiveRecord::Migration[7.2]
def change
create_table :log_outputs do |t|
t.bigint :loggable_id, null: false
t.string :loggable_type, null: false
t.text :output
t.timestamps
end
end
end

View File

@@ -0,0 +1,14 @@
class CreateAddOns < ActiveRecord::Migration[7.2]
def change
create_table :add_ons do |t|
t.references :cluster, null: false, foreign_key: true
t.string :name, null: false
t.string :chart_type, null: false
t.integer :status, null: false, default: 0
t.jsonb :metadata, default: {}
t.timestamps
end
add_index :add_ons, [ :cluster_id, :name ], unique: true
end
end

View File

@@ -0,0 +1,10 @@
class CreateProjectAddOns < ActiveRecord::Migration[7.2]
def change
create_table :project_add_ons do |t|
t.references :project, null: false, foreign_key: true
t.references :add_on, null: false, foreign_key: true
t.timestamps
end
end
end

View File

@@ -0,0 +1,10 @@
class CreateDeployments < ActiveRecord::Migration[7.2]
def change
create_table :deployments do |t|
t.references :build, null: false, foreign_key: true
t.integer :status, default: 0, null: false
t.timestamps
end
end
end

View File

@@ -0,0 +1,8 @@
class CreateCronSchedules < ActiveRecord::Migration[7.2]
def change
create_table :cron_schedules do |t|
t.references :service, null: false, foreign_key: true
t.string :schedule, null: false
end
end
end

View File

@@ -0,0 +1,10 @@
class CreateDomains < ActiveRecord::Migration[7.2]
def change
create_table :domains do |t|
t.references :service, null: false, foreign_key: true
t.string :domain_name, null: false
t.timestamps
end
end
end

112
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_09_25_205204) do
ActiveRecord::Schema[7.2].define(version: 2024_09_25_212945) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -42,6 +42,18 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_25_205204) do
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "add_ons", force: :cascade do |t|
t.bigint "cluster_id", null: false
t.string "name", null: false
t.string "chart_type", null: false
t.integer "status", default: 0, null: false
t.jsonb "metadata", default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["cluster_id", "name"], name: "index_add_ons_on_cluster_id_and_name", unique: true
t.index ["cluster_id"], name: "index_add_ons_on_cluster_id"
end
create_table "announcements", force: :cascade do |t|
t.datetime "published_at"
t.string "announcement_type"
@@ -51,6 +63,60 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_25_205204) do
t.datetime "updated_at", null: false
end
create_table "builds", force: :cascade do |t|
t.bigint "project_id", null: false
t.string "repository_url"
t.string "git_sha"
t.string "commit_message"
t.integer "status", default: 0
t.string "commit_sha", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["project_id"], name: "index_builds_on_project_id"
end
create_table "clusters", force: :cascade do |t|
t.string "name", null: false
t.jsonb "kubeconfig", default: {}, null: false
t.bigint "user_id", null: false
t.integer "status", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_clusters_on_user_id"
end
create_table "cron_schedules", force: :cascade do |t|
t.bigint "service_id", null: false
t.string "schedule", null: false
t.index ["service_id"], name: "index_cron_schedules_on_service_id"
end
create_table "deployments", force: :cascade do |t|
t.bigint "build_id", null: false
t.integer "status", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["build_id"], name: "index_deployments_on_build_id"
end
create_table "domains", force: :cascade do |t|
t.bigint "service_id", null: false
t.string "domain_name", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["service_id"], name: "index_domains_on_service_id"
end
create_table "environment_variables", force: :cascade do |t|
t.string "name", null: false
t.text "value"
t.bigint "project_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["project_id", "name"], name: "index_environment_variables_on_project_id_and_name", unique: true
t.index ["project_id"], name: "index_environment_variables_on_project_id"
end
create_table "friendly_id_slugs", force: :cascade do |t|
t.string "slug", null: false
t.integer "sluggable_id", null: false
@@ -62,6 +128,14 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_25_205204) do
t.index ["sluggable_type", "sluggable_id"], name: "index_friendly_id_slugs_on_sluggable_type_and_sluggable_id"
end
create_table "log_outputs", force: :cascade do |t|
t.bigint "loggable_id", null: false
t.string "loggable_type", null: false
t.text "output"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "noticed_events", force: :cascade do |t|
t.string "type"
t.string "record_type"
@@ -86,6 +160,32 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_25_205204) do
t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient"
end
create_table "project_add_ons", force: :cascade do |t|
t.bigint "project_id", null: false
t.bigint "add_on_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["add_on_id"], name: "index_project_add_ons_on_add_on_id"
t.index ["project_id"], name: "index_project_add_ons_on_project_id"
end
create_table "projects", force: :cascade do |t|
t.string "name", null: false
t.string "repository_url", null: false
t.string "branch", default: "main", null: false
t.bigint "cluster_id", null: false
t.boolean "autodeploy", default: true, null: false
t.string "dockerfile_path", default: "./Dockerfile", null: false
t.string "docker_build_context_directory", default: ".", null: false
t.string "docker_command"
t.string "predeploy_command"
t.integer "status", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["cluster_id"], name: "index_projects_on_cluster_id"
t.index ["name"], name: "index_projects_on_name", unique: true
end
create_table "services", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "provider"
@@ -118,5 +218,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_25_205204) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "add_ons", "clusters"
add_foreign_key "builds", "projects"
add_foreign_key "clusters", "users"
add_foreign_key "cron_schedules", "services"
add_foreign_key "deployments", "builds"
add_foreign_key "domains", "services"
add_foreign_key "environment_variables", "projects"
add_foreign_key "project_add_ons", "add_ons"
add_foreign_key "project_add_ons", "projects"
add_foreign_key "projects", "clusters"
add_foreign_key "services", "users"
end

View File

@@ -0,0 +1,59 @@
# NOTE: only doing this in development as some production environments (Heroku)
# NOTE: are sensitive to local FS writes, and besides -- it's just not proper
# NOTE: to have a dev-mode tool do its thing in production.
if Rails.env.development?
require 'annotate'
task :set_annotation_options do
# You can override any of these by setting an environment variable of the
# same name.
Annotate.set_defaults(
'active_admin' => 'false',
'additional_file_patterns' => [],
'routes' => 'false',
'models' => 'true',
'position_in_routes' => 'before',
'position_in_class' => 'before',
'position_in_test' => 'before',
'position_in_fixture' => 'before',
'position_in_factory' => 'before',
'position_in_serializer' => 'before',
'show_foreign_keys' => 'true',
'show_complete_foreign_keys' => 'false',
'show_indexes' => 'true',
'simple_indexes' => 'false',
'model_dir' => 'app/models',
'root_dir' => '',
'include_version' => 'false',
'require' => '',
'exclude_tests' => 'false',
'exclude_fixtures' => 'false',
'exclude_factories' => 'false',
'exclude_serializers' => 'false',
'exclude_scaffolds' => 'true',
'exclude_controllers' => 'true',
'exclude_helpers' => 'true',
'exclude_sti_subclasses' => 'false',
'ignore_model_sub_dir' => 'false',
'ignore_columns' => nil,
'ignore_routes' => nil,
'ignore_unknown_models' => 'false',
'hide_limit_column_types' => 'integer,bigint,boolean',
'hide_default_column_types' => 'json,jsonb,hstore',
'skip_on_db_migrate' => 'false',
'format_bare' => 'true',
'format_rdoc' => 'false',
'format_yard' => 'false',
'format_markdown' => 'false',
'sort' => 'false',
'force' => 'false',
'frozen' => 'false',
'classified_sort' => 'true',
'trace' => 'false',
'wrapper_open' => nil,
'wrapper_close' => nil,
'with_comment' => 'true'
)
end
Annotate.load_tasks
end

32
test/fixtures/add_ons.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
# == Schema Information
#
# Table name: add_ons
#
# id :bigint not null, primary key
# chart_type :string not null
# metadata :jsonb
# name :string not null
# status :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# cluster_id :bigint not null
#
# Indexes
#
# index_add_ons_on_cluster_id (cluster_id)
# index_add_ons_on_cluster_id_and_name (cluster_id,name) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (cluster_id => clusters.id)
#
# This model initially had no columns defined. If you add columns to the
# model remove the "{}" from the fixture names and add the columns immediately
# below each fixture, per the syntax in the comments below
#
one: {}
# column: value
#
two: {}
# column: value

View File

@@ -1,4 +1,15 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# == Schema Information
#
# Table name: announcements
#
# id :bigint not null, primary key
# announcement_type :string
# description :text
# name :string
# published_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
#
one:
published_at: 2024-09-25 13:51:47

32
test/fixtures/builds.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
# == Schema Information
#
# Table name: builds
#
# id :bigint not null, primary key
# commit_message :string
# commit_sha :string not null
# git_sha :string
# repository_url :string
# status :integer default(0)
# created_at :datetime not null
# updated_at :datetime not null
# project_id :bigint not null
#
# Indexes
#
# index_builds_on_project_id (project_id)
#
# Foreign Keys
#
# fk_rails_... (project_id => projects.id)
#
# This model initially had no columns defined. If you add columns to the
# model remove the "{}" from the fixture names and add the columns immediately
# below each fixture, per the syntax in the comments below
#
one: {}
# column: value
#
two: {}
# column: value

30
test/fixtures/clusters.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
# == Schema Information
#
# Table name: clusters
#
# id :bigint not null, primary key
# kubeconfig :jsonb not null
# name :string not null
# status :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_clusters_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
# This model initially had no columns defined. If you add columns to the
# model remove the "{}" from the fixture names and add the columns immediately
# below each fixture, per the syntax in the comments below
#
one: {}
# column: value
#
two: {}
# column: value

26
test/fixtures/cron_schedules.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
# == Schema Information
#
# Table name: cron_schedules
#
# id :bigint not null, primary key
# schedule :string not null
# service_id :bigint not null
#
# Indexes
#
# index_cron_schedules_on_service_id (service_id)
#
# Foreign Keys
#
# fk_rails_... (service_id => services.id)
#
# This model initially had no columns defined. If you add columns to the
# model remove the "{}" from the fixture names and add the columns immediately
# below each fixture, per the syntax in the comments below
#
one: {}
# column: value
#
two: {}
# column: value

Some files were not shown because too many files have changed in this diff Show More