Merge branch 'main' into celina__token_interface

This commit is contained in:
Celina Lopez
2025-11-20 12:00:49 -08:00
264 changed files with 4544 additions and 793 deletions

View File

@@ -137,6 +137,7 @@ rails console
4. **Kubernetes Resources**: Update templates in `resources/k8/` for deployment changes
5. **Migrations**: Use strong migrations practices for zero-downtime deployments
6. **Linting**: Always run `rubocop -A` at the end of every development cycle
7. **Testing**: If you are going to create a spec, make sure to run it at the end of the development cycle. Don't write specs for validations or associations. Don't create many specs, keep them short and test multiple things in a single spec if it is conveniant
## Important Patterns

View File

@@ -39,6 +39,9 @@ RUN apt-get update -qq && \
RUN curl -fL https://app.getambassador.io/download/tel2oss/releases/download/v2.21.1/telepresence-linux-amd64 -o /usr/local/bin/telepresence && \
chmod a+x /usr/local/bin/telepresence
# Install pack CLI for Cloud Native Buildpacks
RUN curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.38.2/pack-v0.38.2-linux.tgz" | tar -xz -C /usr/local/bin
# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
@@ -87,6 +90,7 @@ COPY --from=build /rails /rails
COPY --from=build /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=build /usr/local/bin/helm /usr/local/bin/helm
COPY --from=build /usr/local/bin/telepresence /usr/local/bin/telepresence
COPY --from=build /usr/local/bin/pack /usr/local/bin/pack
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

View File

@@ -101,7 +101,7 @@ GEM
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.3)
avo (3.25.1)
avo (3.25.3)
actionview (>= 6.1)
active_link_to
activerecord (>= 6.1)
@@ -120,7 +120,7 @@ GEM
avo-heroicons (0.1.1)
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
benchmark (0.5.0)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.18.6)
@@ -140,7 +140,7 @@ GEM
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
crack (1.0.0)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
@@ -152,7 +152,7 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1)
date (3.4.1)
date (3.5.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
@@ -192,7 +192,7 @@ GEM
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
erb (5.1.1)
erb (5.1.3)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
@@ -244,7 +244,7 @@ GEM
fugit (>= 1.11.0)
railties (>= 6.1.0)
thor (>= 1.0.0)
hashdiff (1.2.0)
hashdiff (1.2.1)
hashie (5.0.0)
http (5.2.0)
addressable (~> 2.8)
@@ -325,8 +325,8 @@ GEM
net-smtp
marcel (1.1.0)
matrix (0.4.2)
meta-tags (2.22.1)
actionpack (>= 6.0.0, < 8.1)
meta-tags (2.22.2)
actionpack (>= 6.0.0, < 8.2)
method_source (1.1.0)
mime-types (3.5.2)
mime-types-data (~> 3.2015)
@@ -475,7 +475,7 @@ GEM
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
rake (13.3.1)
rdoc (6.15.0)
erb
psych (>= 4.0.0)
@@ -491,7 +491,7 @@ GEM
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
rexml (3.4.1)
rexml (3.4.4)
rspec-core (3.13.5)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
@@ -549,7 +549,7 @@ GEM
ruby-vips (2.2.2)
ffi (~> 1.12)
logger
rubyzip (3.2.0)
rubyzip (3.2.1)
sanitize (6.1.3)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
@@ -610,7 +610,7 @@ GEM
railties (>= 7.0.0)
thor (1.4.0)
tilt (2.4.0)
timeout (0.4.3)
timeout (0.4.4)
tsort (0.2.0)
turbo-rails (2.0.17)
actionpack (>= 7.1.0)
@@ -625,8 +625,8 @@ GEM
uri (1.0.3)
useragent (0.16.11)
version_gem (1.1.4)
view_component (4.0.2)
activesupport (>= 7.1.0, < 8.1)
view_component (4.1.0)
activesupport (>= 7.1.0, < 8.2)
concurrent-ruby (~> 1)
warden (1.2.9)
rack (>= 2.0.9)
@@ -635,7 +635,7 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.25.1)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)

View File

@@ -1,11 +1,11 @@
<br/>
<div align="center">
<a href="https://github.com/CanineHQ/canine">
<img src="https://github.com/CanineHQ/canine/blob/main/public/images/logo-full.png?raw=true" alt="Logo" height="100">
<img src="https://github.com/CanineHQ/canine/blob/main/public/images/logo-full.webp?raw=true" alt="Logo" height="100">
</a>
<h3 align="center">Canine</h3>
<p align="center">
Power of Kubernetes, Simplicity of Heroku
A developer-friendly PaaS for your Kubernetes
<br/>
<br/>
<a href="https://docs.canine.sh"><strong>Explore the docs »</strong></a>
@@ -22,10 +22,36 @@ Power of Kubernetes, Simplicity of Heroku
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/canine)](https://artifacthub.io/packages/search?repo=canine)
![Deployment Screenshot](https://raw.githubusercontent.com/CanineHQ/canine/refs/heads/main/public/images/deployment_styled.png)
![Deployment Screenshot](https://raw.githubusercontent.com/CanineHQ/canine/refs/heads/main/public/images/deployment_styled.webp)
## About the project
Canine is an easy to use intuitive deployment platform for Kubernetes clusters.
Canine is a self-hosted Kubernetes deployment platform that brings the simplicity of Platform-as-a-Service (like Heroku) to your own Kubernetes infrastructure. Deploy applications with git push, manage services through an intuitive web interface, and leverage the full power of Kubernetes without writing YAML.
### Why Canine?
**Kubernetes Made Simple**
Stop wrestling with kubectl and complex YAML manifests. Canine provides a clean web interface to deploy, scale, and manage your applications on Kubernetes.
**Git-Driven Deployments**
Connect your GitHub or GitLab repository and deploy automatically on every push. Canine builds your Docker images and handles the entire deployment pipeline.
**Full Kubernetes Control**
Unlike hosted PaaS solutions, you maintain complete control over your infrastructure. Run Canine on any Kubernetes cluster - cloud, on-premise, or edge.
### Core Features
| Feature | Description |
|---------|-------------|
| **🚀 Automated Deployments** | Git webhook integration for continuous deployment from GitHub/GitLab |
| **🐳 Built-in Image Building** | Automatic Docker image builds using Dockerfile or buildpacks |
| **🔧 Service Management** | Deploy web services, background workers, and scheduled cron jobs |
| **📊 Resource Constraints** | Configure CPU, memory, and GPU limits for your applications |
| **🌐 Domain & SSL** | Custom domain management with DNS integration and automatic SSL |
| **🔐 Secrets & Config** | Environment variables and Kubernetes secrets management |
| **💾 Persistent Storage** | Volume management for stateful applications and databases |
| **👥 Multi-tenancy** | Account-based isolation with team collaboration and access control |
| **⚙️ Custom Pod Templates** | Advanced Kubernetes pod customization with YAML configuration |
## Requirements
@@ -66,4 +92,8 @@ For more information & pricing, take a look at our landing page [https://canine.
## License
[Apache 2.0 License](https://github.com/CanineHQ/canine/blob/main/LICENSE)
Canine is released under the [Apache 2.0 License](https://github.com/CanineHQ/canine/blob/main/LICENSE).
You are free to use, modify, and distribute this software for commercial and non-commercial purposes. See the LICENSE file for full details.
For commercial support, enterprise features, or managed hosting, visit [https://canine.sh](https://canine.sh).

View File

@@ -0,0 +1,78 @@
class Buildpacks::Details
extend LightService::Action
expects :namespace, :name
promises :result
# Struct for individual buildpack versions
BuildpackVersion = Struct.new(
:version,
:link,
keyword_init: true
) do
def self.from_hash(hash)
new(
version: hash["version"],
link: hash["_link"]
)
end
end
# Struct for latest buildpack information (details endpoint has fewer fields)
BuildpackLatestDetails = Struct.new(
:version,
:namespace,
:name,
:description,
:homepage,
:licenses,
:stacks,
:id,
:verified,
keyword_init: true
) do
VERIFIED_NAMESPACES = %w[io.buildpacks paketo-buildpacks heroku tanzu-buildpacks].freeze
def self.from_hash(hash)
new(
version: hash["version"],
namespace: hash["namespace"],
name: hash["name"],
description: hash["description"],
homepage: hash["homepage"],
licenses: hash["licenses"],
stacks: hash["stacks"],
id: hash["id"],
verified: VERIFIED_NAMESPACES.include?(hash["namespace"])
)
end
end
# Struct for buildpack details result
BuildpackDetailsResult = Struct.new(
:latest,
:versions,
keyword_init: true
) do
def self.from_hash(hash)
new(
latest: BuildpackLatestDetails.from_hash(hash["latest"]),
versions: hash["versions"]&.map { |v| BuildpackVersion.from_hash(v) } || []
)
end
end
executed do |context|
response = HTTParty.get(
"https://registry.buildpacks.io/api/v1/buildpacks/#{context.namespace}/#{context.name}",
headers: {
"Accept" => "application/json"
}
)
if response.success?
context.result = BuildpackDetailsResult.from_hash(response.parsed_response)
else
context.fail_and_return!("Failed to fetch buildpack details: #{response.code}: #{response.message}")
end
end
end

View File

@@ -0,0 +1,94 @@
class Buildpacks::Search
extend LightService::Action
expects :query
promises :results
# Struct for individual buildpack versions
BuildpackVersion = Struct.new(
:version,
:link,
keyword_init: true
) do
def self.from_hash(hash)
new(
version: hash["version"],
link: hash["_link"]
)
end
end
# Struct for latest buildpack information
BuildpackLatest = Struct.new(
:id,
:namespace,
:name,
:version,
:addr,
:yanked,
:description,
:homepage,
:licenses,
:stacks,
:created_at,
:updated_at,
:version_major,
:version_minor,
:version_patch,
:verified,
keyword_init: true
) do
VERIFIED_NAMESPACES = %w[io.buildpacks paketo-buildpacks heroku tanzu-buildpacks].freeze
def self.from_hash(hash)
new(
id: hash["id"],
namespace: hash["namespace"],
name: hash["name"],
version: hash["version"],
addr: hash["addr"],
yanked: hash["yanked"],
description: hash["description"],
homepage: hash["homepage"],
licenses: hash["licenses"],
stacks: hash["stacks"],
created_at: hash["created_at"],
updated_at: hash["updated_at"],
version_major: hash["version_major"],
version_minor: hash["version_minor"],
version_patch: hash["version_patch"],
verified: VERIFIED_NAMESPACES.include?(hash["namespace"])
)
end
end
# Struct for complete buildpack search result
BuildpackResult = Struct.new(
:latest,
:versions,
keyword_init: true
) do
def self.from_hash(hash)
new(
latest: BuildpackLatest.from_hash(hash["latest"]),
versions: hash["versions"]&.map { |v| BuildpackVersion.from_hash(v) } || []
)
end
end
executed do |context|
response = HTTParty.get(
"https://registry.buildpacks.io/api/v1/search",
query: { matches: context.query },
headers: {
"Accept" => "application/json"
}
)
if response.success?
parsed_results = response.parsed_response.map { |result| BuildpackResult.from_hash(result) }
context.results = parsed_results
else
context.fail_and_return!("Failed to search buildpacks: #{response.code}: #{response.message}")
end
end
end

View File

@@ -11,7 +11,7 @@ class Clusters::Install
]
def self.recipe(cluster, user)
recipe = if cluster.account.stack_manager.present?
recipe = if cluster.account.stack_manager.present? && cluster.kubeconfig.blank?
stack_manager = cluster.account.stack_manager
stack_manager.stack.connect(user).install_recipe
else

View File

@@ -20,6 +20,7 @@ class EnvironmentVariables::BulkUpdate
project.environment_variables.create!(
name: ev[:name].strip.upcase,
value: ev[:value].strip,
storage_type: ev[:storage_type] || :config,
current_user: context.current_user
)
end
@@ -33,11 +34,18 @@ class EnvironmentVariables::BulkUpdate
updated_variables.each do |ev|
env_variable = env_variable_data.find { |evd| evd[:name] == ev.name }
# Skip updating value if keep_existing_value flag is set
next if env_variable[:keep_existing_value] == "true"
if env_variable[:keep_existing_value] == "true"
update_attrs = {}
else
update_attrs = {}
update_attrs[:value] = env_variable[:value].strip if env_variable[:value] != ev.value
end
update_attrs[:storage_type] = env_variable[:storage_type] if env_variable[:storage_type] && env_variable[:storage_type] != ev.storage_type
unless env_variable[:value] == ev.value
if update_attrs.any?
ev.update!(
value: env_variable[:value].strip,
**update_attrs,
current_user: context.current_user
)
ev.events.create!(

View File

@@ -9,13 +9,10 @@ module Projects
:repository_url,
:branch,
:cluster_id,
:docker_build_context_directory,
:docker_command,
:dockerfile_path,
:container_registry_url,
:predeploy_command,
:project_fork_status,
:project_fork_cluster_id,
:project_fork_cluster_id
)
end
@@ -58,7 +55,10 @@ module Projects
{
provider: project.project_credential_provider.provider,
driver: BuildConfiguration::DEFAULT_BUILDER,
image_repository: project.repository_url
build_type: :dockerfile,
image_repository: project.repository_url,
context_directory: ".",
dockerfile_path: "./Dockerfile"
}
end
@@ -69,6 +69,7 @@ module Projects
end
steps << Projects::ValidateNamespaceAvailability
steps << Projects::InitializeBuildPacks
steps << Projects::Save
# Only register webhook in cloud mode

View File

@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Projects
class InitializeBuildPacks
extend LightService::Action
expects :build_configuration, :params
promises :build_packs
def self.fetch_buildpack_details!(build_pack)
result = Buildpacks::Details.execute(
namespace: build_pack.namespace,
name: build_pack.name
)
build_pack.details = result.result.to_h
build_pack
end
executed do |context|
context.build_packs = []
build_configuration = context.build_configuration
next context unless build_configuration&.buildpacks?
build_packs_params = context.params
.dig(:project, :build_configuration, :build_packs_attributes) || []
next context unless build_packs_params
context.build_packs = build_packs_params.map.with_index do |pack_params, build_order|
permitted = pack_params.permit(:namespace, :name, :version, :reference_type)
build_pack = build_configuration.build_packs.build(permitted.merge(build_order:))
fetch_buildpack_details!(build_pack)
end
end
end
end

View File

@@ -12,7 +12,8 @@ module Projects
build_configuration:,
params:
).reduce(
Projects::UpdateSave
Projects::UpdateSave,
Projects::UpdateBuildPacks
)
end

View File

@@ -0,0 +1,55 @@
class Projects::UpdateBuildPacks
extend LightService::Action
expects :build_configuration, :params
executed do |context|
build_configuration = context.build_configuration
next context unless build_configuration&.buildpacks?
build_packs_params = context.params
.dig(:project, :build_configuration, :build_packs_attributes) || []
# Create a hash of existing build packs keyed by build_pack.key
existing_packs = {}
build_configuration.build_packs.each do |pack|
existing_packs[pack.key] = pack
end
# Track which build packs are in the params and their order
incoming_keys = []
ActiveRecord::Base.transaction do
# Process each incoming build pack
build_packs_params.each_with_index do |pack_params, build_order|
permitted = pack_params.permit(:namespace, :name, :version, :reference_type)
namespace = permitted[:namespace]
name = permitted[:name]
key = "#{namespace}/#{name}"
next if namespace.blank? || name.blank?
incoming_keys << key unless incoming_keys.include?(key)
if existing_packs[key]
# Build pack already exists, update its order
build_pack = existing_packs[key]
build_pack.build_order = build_order
else
# Build pack doesn't exist, create it and fetch details
build_pack = build_configuration.build_packs.build(permitted.merge(build_order:))
Projects::InitializeBuildPacks.fetch_buildpack_details!(build_pack)
end
build_pack.save!
end
# Delete build packs that are not in the incoming params (only persisted ones)
packs_to_delete = build_configuration.build_packs.reject do |pack|
incoming_keys.include?(pack.key)
end
packs_to_delete.each(&:destroy!)
end
context
end
end

View File

@@ -0,0 +1,9 @@
class ResourceConstraints::Create
extend LightService::Organizer
def self.call(resource_constraint, params)
with(resource_constraint:, params:).reduce(
ResourceConstraints::Save
)
end
end

View File

@@ -0,0 +1,29 @@
class ResourceConstraints::Save
extend LightService::Action
expects :resource_constraint, :params
executed do |context|
# Get params hash
rc_params = context.params
# Convert blank strings to nil
rc_params.each do |key, value|
rc_params[key] = nil if value.blank?
end
# Convert CPU cores to millicores
if rc_params[:cpu_request].present?
rc_params[:cpu_request] = (rc_params[:cpu_request].to_f * 1000).to_i
end
if rc_params[:cpu_limit].present?
rc_params[:cpu_limit] = (rc_params[:cpu_limit].to_f * 1000).to_i
end
context.resource_constraint.assign_attributes(rc_params)
context.resource_constraint.save!
rescue StandardError => e
context.fail_and_return!(e.message)
end
end

View File

@@ -0,0 +1,9 @@
class ResourceConstraints::Update
extend LightService::Organizer
def self.call(resource_constraint, params)
with(resource_constraint:, params:).reduce(
ResourceConstraints::Save
)
end
end

View File

@@ -76,4 +76,12 @@
.max-w-9xl {
max-width: 96rem;
}
.slider::-webkit-slider-thumb {
@apply appearance-none w-4 h-4 rounded-full bg-primary cursor-pointer;
}
.slider::-moz-range-thumb {
@apply w-4 h-4 rounded-full bg-primary cursor-pointer border-0;
}

View File

@@ -0,0 +1,16 @@
class Avo::Resources::BuildPack < Avo::BaseResource
# self.includes = []
# self.attachments = []
# self.search = {
# query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) }
# }
def fields
field :id, as: :id
field :build_configuration, as: :belongs_to
field :namespace, as: :text
field :name, as: :text
field :version, as: :text
field :details, as: :code
end
end

View File

@@ -7,7 +7,6 @@ class Avo::Resources::Project < Avo::BaseResource
field :branch, as: :text
field :status, as: :select, options: Project.statuses.keys.map { |status| [ status.humanize, status ] }
field :autodeploy, as: :boolean
field :docker_command, as: :text
field :dockerfile_path, as: :text
field :docker_build_context_directory, as: :text
field :container_registry_url, as: :text

View File

@@ -5,8 +5,11 @@ class AddOns::ProcessesController < AddOns::BaseController
def show
client = K8::Client.new(active_connection)
@logs = client.get_pod_log(params[:id], @add_on.name)
@pod_events = client.get_pod_events(params[:id], @add_on.name)
@logs = client.get_pod_log(params[:id], @add_on.name)
rescue Kubeclient::HttpError => e
@logs = ""
@error = e.to_s
rescue Kubeclient::ResourceNotFoundError
flash[:alert] = "Pod #{params[:id]} not found"
redirect_to add_on_processes_path(@add_on)

View File

@@ -132,7 +132,14 @@ class AddOnsController < ApplicationController
if params[:add_on][:metadata].present?
params[:add_on][:metadata] = params[:add_on][:metadata][params[:add_on][:chart_type]]
end
params.require(:add_on).permit(:cluster_id, :chart_type, :chart_url, :name, metadata: {}, values: {})
params.require(:add_on).permit(
:cluster_id,
:chart_type,
:chart_url,
:name,
metadata: {},
values: {}
)
# Uncomment to use Pundit permitted attributes
# params.require(:add_on).permit(policy(@add_on).permitted_attributes)

View File

@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/3.0/controllers.html
class Avo::BuildPacksController < Avo::ResourcesController
end

View File

@@ -0,0 +1,22 @@
class BuildPacksController < ApplicationController
def search
result = Buildpacks::Search.execute(query: params[:q])
if result.success?
render json: result.results
else
render json: { error: "Failed to search buildpacks" }, status: :unprocessable_entity
end
end
def details
result = Buildpacks::Details.execute(
namespace: params[:namespace],
name: params[:name]
)
if result.success?
render json: result.result
else
render json: { error: "Failed to fetch buildpack details" }, status: :unprocessable_entity
end
end
end

View File

@@ -4,7 +4,7 @@ class Projects::EnvironmentVariablesController < Projects::BaseController
before_action :set_project
def index
@environment_variables = @project.environment_variables
@environment_variables = @project.environment_variables.order(:name)
end
def show
@@ -13,7 +13,7 @@ class Projects::EnvironmentVariablesController < Projects::BaseController
end
def download
env_content = @project.environment_variables.map { |ev| "#{ev.name}=#{ev.value}" }.join("\n")
env_content = @project.environment_variables.order(:name).map { |ev| "#{ev.name}=#{ev.value}" }.join("\n")
send_data env_content,
filename: "#{@project.name.parameterize}.env",
type: 'text/plain',

View File

@@ -20,6 +20,9 @@ class Projects::ProcessesController < Projects::BaseController
format.html
format.turbo_stream { render turbo_stream: turbo_stream.update("logs", partial: "log_outputs/log_chunk", locals: { logs: @logs }) }
end
rescue Kubeclient::HttpError => e
@logs = ""
@error = e.to_s
rescue Kubeclient::ResourceNotFoundError
flash[:alert] = "Pod #{params[:id]} not found"
redirect_to project_processes_path(@project)

View File

@@ -5,11 +5,20 @@ class Projects::Services::JobsController < Projects::Services::BaseController
def create
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
job_name = "#{@service.name}-manual-#{timestamp}"
kubectl = K8::Kubectl.new(K8::Connection.new(@project.cluster, current_user))
kubectl = K8::Kubectl.new(active_connection)
kubectl.call(
"-n #{@project.name} create job #{job_name} --from=cronjob/#{@service.name}"
)
render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false
end
redirect_to project_services_path(@project), notice: "Job #{job_name} created."
def destroy
job_name = params[:id]
kubectl = K8::Kubectl.new(active_connection)
kubectl.call(
"-n #{@project.name} delete job #{job_name}"
)
render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false
end
end

View File

@@ -0,0 +1,55 @@
class Projects::Services::ResourceConstraintsController < Projects::Services::BaseController
before_action :set_service
def create
result = ResourceConstraints::Create.call(@service.build_resource_constraint, resource_constraint_params)
if result.success?
@service.updated!
render_partial
else
render :new, status: :unprocessable_entity
end
end
def show
render_partial
end
def new
render_partial(show_form: true)
end
def update
result = ResourceConstraints::Update.call(@service.resource_constraint, resource_constraint_params)
@service.updated!
if result.success?
render_partial
else
raise StandardError, result.message
end
end
def destroy
@service.resource_constraint.destroy
@service.updated!
render_partial
end
private
def render_partial(locals = {})
render partial: "projects/services/resource_constraints/show", locals: { service: @service, resource_constraint: @service.resource_constraint }.merge(locals)
end
def resource_constraint_params
params.require(:resource_constraint).permit(
:cpu_request,
:cpu_limit,
:memory_request,
:memory_limit,
:gpu_request
)
end
end

View File

@@ -19,6 +19,12 @@ class Projects::ServicesController < Projects::BaseController
end
end
def show
@service = @project.services.find(params[:id])
@tab = params[:tab] || "overview"
render partial: "projects/services/show", locals: { service: @service, tab: @tab }, layout: false
end
def update
result = Services::Update.execute(service: @service, params: params)
if result.success?

View File

@@ -3,26 +3,26 @@ class StaticController < ApplicationController
skip_before_action :authenticate_user!
ILLUSTRATIONS = [
{
src: "/images/illustrations/design_2.png",
src: "/images/illustrations/design_2.webp",
title: "You enjoy vendor lock-in",
description: "Canine makes it possible to deploy to 230+ cloud providers, with the same UI.",
background_color: "bg-green-100"
},
{
src: "/images/illustrations/design_3.png",
src: "/images/illustrations/design_3.webp",
title: "You like spending more, for less",
description: "Pay Hetzner like pricing for Heroku like dev experiences.",
background_color: "bg-yellow-100"
},
{
src: "/images/illustrations/design_4.png",
src: "/images/illustrations/design_4.webp",
title: "You don't want modern infrastructure",
description: "Would rather cobble together SSH scripts? Look elsewhere.",
background_color: "bg-blue-100"
},
{
src: "/images/illustrations/design_5.png",
src: "/images/illustrations/design_5.webp",
title: "You like configuring infrastructure more than building apps",
description: "Canine makes your infrastructure \"just work\".",
background_color: "bg-violet-100"

View File

@@ -2,10 +2,16 @@ module ApplicationHelper
include Pagy::Frontend
def custom_pagy_nav(pagy)
html = '<div class="join">'
html = '<div class="flex items-center gap-2">'
html << pagy_nav_prev(pagy) if pagy.prev
pagy.series.each do |item|
html << link_to(item, url_for(page: item), class: "btn btn-primary join-item btn-active btn-sm")
if item.to_s == pagy.page.to_s
html << link_to(item, url_for(page: item), class: "btn btn-sm btn-neutral min-w-[2.5rem] pointer-events-none")
elsif item.to_s == "gap"
html << link_to("", url_for(page: item), class: "btn btn-sm btn-ghost min-w-[2.5rem] pointer-events-none btn-disabled")
else
html << link_to(item, url_for(page: item), class: "btn btn-sm btn-ghost min-w-[2.5rem]")
end
end
html << pagy_nav_next(pagy) if pagy.next
html << "</div>"
@@ -14,17 +20,23 @@ module ApplicationHelper
end
def pagy_nav_prev(pagy)
'<a aria-label="pagination-prev" class="btn join-item btn-sm gap-2" href="' + pagy_url_for(pagy, pagy.prev) + '">' +
'<a aria-label="pagination-prev" class="btn btn-sm gap-2" href="' + pagy_url_for(pagy, pagy.prev) + '">' +
'<iconify-icon icon="lucide:chevron-left" height="16"></iconify-icon>' +
"</a>"
end
def pagy_nav_next(pagy)
'<a aria-label="pagination-prev" class="btn join-item btn-sm gap-2" href="' + pagy_url_for(pagy, pagy.next) + '">' +
'<a aria-label="pagination-prev" class="btn btn-sm gap-2" href="' + pagy_url_for(pagy, pagy.next) + '">' +
'<iconify-icon icon="lucide:chevron-right" height="16"></iconify-icon>' +
"</a>"
end
def pagy_info_text(pagy)
from = ((pagy.page - 1) * pagy.limit) + 1
to = [ pagy.page * pagy.limit, pagy.count ].min
"Showing #{from}-#{to} of #{pagy.count}"
end
def in_namespace?(namespace)
controller.controller_path.include?(namespace)
end

View File

@@ -0,0 +1,5 @@
module ServicesHelper
def services_layout(service, tab, &block)
render layout: 'projects/services/layout', locals: { service:, tab: }, &block
end
end

View File

@@ -34,7 +34,9 @@ module StorageHelper
SIZE_UNITS.to_a.reverse.each do |unit, bytes|
if integer >= bytes
value = (integer.to_f / bytes).round(2)
return "#{value}#{unit}i"
# Remove unnecessary trailing zeros and decimal point
formatted_value = value % 1 == 0 ? value.to_i : value
return "#{formatted_value}#{unit}i"
end
end
integer.to_s

View File

@@ -0,0 +1,257 @@
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
export default class extends Controller {
static targets = ["list", "template", "modal", "baseBuilder", "availableBuildpacks", "selectedBuildpacks"]
static values = {
packs: Object
}
connect() {
this.selectedPacks = []
this.initializeSortable()
// Listen for buildpack selection from search
this.element.addEventListener("buildpack-search:buildpack-selected", this.handleSearchSelection.bind(this))
}
disconnect() {
this.element.removeEventListener("buildpack-search:buildpack-selected", this.handleSearchSelection.bind(this))
}
handleSearchSelection(event) {
const { namespace, name, version, description } = event.detail
// Create a pack object with buildpack.webp as the default image
const pack = {
key: `${namespace}/${name}`,
namespace: namespace,
name: name,
version: version || '',
image: '/images/languages/buildpack.webp',
description: description || '',
reference_type: 'registry'
}
// Check if already selected
if (!this.selectedPacks.some(p => p.key === pack.key)) {
this.selectedPacks.push(pack)
this.displayAvailableBuildpacks()
this.renderSelectedBuildpacks()
}
}
initializeSortable() {
if (this.hasListTarget) {
this.sortable = Sortable.create(this.listTarget, {
animation: 150,
handle: ".drag-handle",
ghostClass: "opacity-50"
})
}
}
initializeModalSortable() {
if (this.hasSelectedBuildpacksTarget && !this.modalSortable) {
this.modalSortable = Sortable.create(this.selectedBuildpacksTarget, {
animation: 150,
ghostClass: "opacity-50",
onEnd: () => {
this.updateSelectedPacksOrder()
}
})
}
}
updateSelectedPacksOrder() {
// Get the current DOM order and update selectedPacks array
const elements = this.selectedBuildpacksTarget.querySelectorAll('[data-key]')
this.selectedPacks = Array.from(elements).map(el => {
const key = el.dataset.key
return { key, ...this.packsValue[key] }
})
}
openModal() {
// Repopulate selectedPacks from existing buildpacks in the form
this.selectedPacks = this.getExistingBuildpacks()
this.displayAvailableBuildpacks()
this.renderSelectedBuildpacks()
this.modalTarget.showModal()
// Initialize sortable after modal is shown
setTimeout(() => this.initializeModalSortable(), 100)
}
closeModal() {
this.modalTarget.close()
}
getExistingBuildpacks() {
const existingPacks = []
const cards = this.listTarget.querySelectorAll('.card')
cards.forEach(card => {
const namespaceInput = card.querySelector('input[name*="[namespace]"]')
const nameInput = card.querySelector('input[name*="[name]"]')
if (namespaceInput && nameInput) {
const key = `${namespaceInput.value}/${nameInput.value}`
const pack = this.packsValue[key]
if (pack) {
existingPacks.push({ key, ...pack })
}
}
})
return existingPacks
}
displayAvailableBuildpacks() {
const builder = this.baseBuilderTarget.value
const namespace = this.detectNamespace(builder)
if (!namespace) {
this.availableBuildpacksTarget.innerHTML = '<div class="text-sm text-gray-500 p-4 text-center">Please select a base builder first</div>'
return
}
const availablePacks = Object.entries(this.packsValue)
.filter(([key, pack]) => pack.namespace === namespace)
.map(([key, pack]) => ({ key, ...pack }))
if (availablePacks.length === 0) {
this.availableBuildpacksTarget.innerHTML = '<div class="text-sm text-gray-500 p-4 text-center">No buildpacks available for this builder</div>'
return
}
this.renderAvailableBuildpacks(availablePacks)
}
renderAvailableBuildpacks(packs) {
const filteredPacks = packs.filter(pack =>
!this.selectedPacks.some(selected => selected.key === pack.key)
)
if (filteredPacks.length === 0) {
this.availableBuildpacksTarget.innerHTML = '<div class="text-sm text-gray-500 p-4 text-center">All buildpacks selected</div>'
return
}
const html = filteredPacks.map(pack => `
<div class="p-3 hover:bg-base-200 cursor-pointer border-b border-base-300 flex items-center gap-3"
data-action="click->buildpack-fields#selectBuildpack"
data-key="${pack.key}">
<img src="${pack.image}" alt="${pack.key}" class="w-10 h-10 object-contain" />
<div class="flex-1">
<div class="font-medium">${pack.namespace}/${pack.name}</div>
<div class="text-sm text-gray-600">${pack.description}</div>
</div>
</div>
`).join('')
this.availableBuildpacksTarget.innerHTML = html
}
renderSelectedBuildpacks() {
if (this.selectedPacks.length === 0) {
this.selectedBuildpacksTarget.innerHTML = '<div class="text-sm text-gray-500 p-4 text-center">No buildpacks selected</div>'
if (this.modalSortable) {
this.modalSortable.destroy()
this.modalSortable = null
}
return
}
const html = this.selectedPacks.map((pack, index) => `
<div class="p-3 hover:bg-base-200 cursor-move border-b border-base-300 flex items-center gap-3"
data-key="${pack.key}"
data-index="${index}">
<img src="${pack.image}" alt="${pack.key}" class="w-10 h-10 object-contain" />
<div class="flex-1">
<div class="font-medium">${pack.namespace}/${pack.name}</div>
<div class="text-sm text-gray-600">${pack.description}</div>
</div>
<button type="button" class="btn btn-xs btn-circle btn-ghost" data-action="click->buildpack-fields#deselectBuildpack" data-index="${index}">
<iconify-icon icon="mdi:close" width="16" height="16"></iconify-icon>
</button>
</div>
`).join('')
this.selectedBuildpacksTarget.innerHTML = html
// Reinitialize sortable after rendering
if (this.modalSortable) {
this.modalSortable.destroy()
this.modalSortable = null
}
this.initializeModalSortable()
}
detectNamespace(builder) {
if (!builder) return null
if (builder.includes("paketo")) {
return "paketo-buildpacks"
} else if (builder.includes("heroku")) {
return "heroku"
}
return null
}
selectBuildpack(event) {
const key = event.currentTarget.dataset.key
const pack = { key, ...this.packsValue[key] }
this.selectedPacks.push(pack)
this.displayAvailableBuildpacks()
this.renderSelectedBuildpacks()
}
deselectBuildpack(event) {
event.stopPropagation()
const index = parseInt(event.currentTarget.dataset.index)
this.selectedPacks.splice(index, 1)
this.displayAvailableBuildpacks()
this.renderSelectedBuildpacks()
}
addSelectedBuildpacks() {
// Clear existing buildpacks from the list
this.listTarget.innerHTML = ''
this.selectedPacks.forEach((pack, index) => {
const template = this.templateTarget.content || this.templateTarget
const clone = template.cloneNode(true)
const img = clone.querySelector('[data-template-image]')
img.src = pack.image
img.alt = pack.key
const title = clone.querySelector('[data-template-title]')
title.textContent = `${pack.namespace}/${pack.name}`
const description = clone.querySelector('[data-template-description]')
description.textContent = pack.description
const namespaceInput = clone.querySelector('[data-template-namespace]')
namespaceInput.value = pack.namespace
const nameInput = clone.querySelector('[data-template-name]')
nameInput.value = pack.name
const referenceTypeInput = clone.querySelector('[data-template-reference-type]')
referenceTypeInput.value = pack.reference_type
const container = document.createElement('div')
container.appendChild(clone)
this.listTarget.insertAdjacentHTML("beforeend", container.innerHTML)
})
this.closeModal()
}
remove(event) {
event.target.closest(".card").remove()
}
}

View File

@@ -0,0 +1,46 @@
import AsyncSearchDropdownController from "./components/async_search_dropdown_controller"
export default class extends AsyncSearchDropdownController {
async fetchResults(query) {
const response = await fetch(`/build_packs/search?q=${encodeURIComponent(query)}`)
if (!response.ok) {
throw new Error('Failed to fetch buildpacks')
}
const data = await response.json()
return data
}
renderItem(buildpack) {
const latest = buildpack.latest
const verifiedBadge = latest.verified
? '<span class="badge badge-primary badge-sm">Verified</span>'
: ''
return `
<div class="flex justify-between items-start text-left">
<div class="flex flex-col flex-1">
<div class="font-semibold">
${latest.namespace}/${latest.name}
</div>
<div class="text-sm text-base-content/60">${latest.description || 'No description'}</div>
<div class="text-xs text-base-content/70 mt-1">
Latest: ${latest.version} | ${latest.licenses?.join(', ') || 'No license info'}
</div>
</div>
${verifiedBadge}
</div>
`
}
onItemSelect(buildpack, itemElement) {
const latest = buildpack.latest
this.dispatch("buildpack-selected", {
detail: {
namespace: latest.namespace,
name: latest.name,
version: latest.version,
description: latest.description
}
})
}
}

View File

@@ -0,0 +1,177 @@
import { Controller } from "@hotwired/stimulus"
import { debounce } from "../../utils"
/**
* Base controller for async search dropdowns with autocomplete
*
* Child controllers must implement:
* - fetchResults(query): Promise<Array> - Fetch and return search results
* - renderItem(item): String - Return HTML string for a single item
* - onItemSelect(item, itemElement): void - Handle item selection
*
* Optional overrides:
* - getInputElement(): HTMLElement - Get the input element (default: finds input in this.element)
* - shouldSearch(query): Boolean - Determine if search should be performed (default: non-empty query)
* - getDebounceDelay(): Number - Debounce delay in ms (default: 500)
*/
export default class extends Controller {
connect() {
this.input = this.getInputElement()
if (!this.input) {
console.error('AsyncSearchDropdown: No input element found')
return
}
// Disable browser autocomplete
this.input.setAttribute('autocomplete', 'off')
// Create dropdown
this.dropdown = this.createDropdown()
this.element.appendChild(this.dropdown)
// Bind search handler with debounce
this.searchHandler = debounce(this.performSearch.bind(this), this.getDebounceDelay())
this.input.addEventListener('input', this.searchHandler)
// Handle click outside to close dropdown
this.clickOutsideHandler = this.handleClickOutside.bind(this)
document.addEventListener('click', this.clickOutsideHandler)
}
disconnect() {
if (this.input) {
this.input.removeEventListener('input', this.searchHandler)
}
document.removeEventListener('click', this.clickOutsideHandler)
}
createDropdown() {
const dropdown = document.createElement('ul')
dropdown.className = 'hidden absolute z-10 w-full mt-1 menu bg-base-200 block rounded-box shadow-lg max-h-[300px] overflow-y-auto'
return dropdown
}
getInputElement() {
return this.element.querySelector('input')
}
getDebounceDelay() {
return 500
}
shouldSearch(query) {
return query.trim().length > 0
}
async performSearch() {
const query = this.input.value
if (!this.shouldSearch(query)) {
this.hideDropdown()
return
}
try {
this.showLoading()
const results = await this.fetchResults(query)
this.renderResults(results)
} catch (error) {
console.error('Search error:', error)
this.showError(error.message || 'Failed to fetch results')
}
}
renderResults(results) {
if (!results || results.length === 0) {
this.showEmpty()
return
}
this.dropdown.innerHTML = results.map((item, index) => `
<li class="cursor-pointer hover:bg-base-300 p-2" data-index="${index}">
${this.renderItem(item)}
</li>
`).join('')
// Store results for later access
this.currentResults = results
// Add click handlers
this.dropdown.querySelectorAll('li').forEach((li, index) => {
li.addEventListener('click', () => {
this.selectItem(results[index], li)
})
})
this.showDropdown()
}
selectItem(item, itemElement) {
this.onItemSelect(item, itemElement)
this.clearInput()
this.hideDropdown()
}
clearInput() {
if (this.input) {
this.input.value = ''
}
}
showDropdown() {
this.dropdown.classList.remove('hidden')
}
hideDropdown() {
this.dropdown.classList.add('hidden')
this.dropdown.innerHTML = ''
}
showLoading() {
this.dropdown.innerHTML = `
<li class="p-4 text-center flex items-center justify-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
<span>Searching...</span>
</li>
`
this.showDropdown()
}
showError(message) {
this.dropdown.innerHTML = `
<li class="p-4 text-center text-error">
${message}
</li>
`
this.showDropdown()
}
showEmpty() {
this.dropdown.innerHTML = `
<li class="p-4 text-center text-base-content/60">
No results found
</li>
`
this.showDropdown()
}
handleClickOutside(event) {
if (!this.element.contains(event.target)) {
this.hideDropdown()
}
}
// Methods to be implemented by child controllers
async fetchResults(query) {
throw new Error('fetchResults must be implemented by child controller')
}
renderItem(item) {
throw new Error('renderItem must be implemented by child controller')
}
onItemSelect(item, itemElement) {
throw new Error('onItemSelect must be implemented by child controller')
}
}

View File

@@ -1,5 +1,5 @@
import { Controller } from "@hotwired/stimulus"
import { destroy, get } from '@rails/request.js'
import { get } from '@rails/request.js'
export default class extends Controller {
static targets = ["container"]
@@ -11,44 +11,57 @@ export default class extends Controller {
connect() {
const vars = JSON.parse(this.varsValue)
vars.forEach(v => {
this._add(v.name, v.value, v.id)
this._add(v.name, v.value, v.id, false, v.storage_type || 'config')
})
}
add(e) {
e.preventDefault();
this._add("", "", null, true)
this._add("", "", null, true, 'config')
}
_add(name, value, id=null, isNew=false) {
_add(name, value, id=null, isNew=false, storageType='config') {
const container = this.containerTarget;
const div = document.createElement("div");
const isHidden = !isNew && id !== null
const displayValue = isHidden ? '' : value
const placeholder = isHidden ? '••••••••••••••••••••••••' : 'VALUE'
const isSecret = storageType === 'secret'
const lockIcon = isSecret ? 'lucide:lock' : 'lucide:lock-open'
const lockColor = isSecret ? 'text-warning' : 'text-base-content'
div.innerHTML = `
<div class="flex items-center my-4 space-x-2" data-env-id="${id || ''}">
<div class="flex items-center my-4 space-x-2" data-env-id="${id || ''}" data-storage-type="${storageType}">
<input aria-label="Env key" placeholder="KEY" class="input input-bordered focus:outline-offset-0" type="text" name="environment_variables[][name]" value="${name}">
${isHidden ? `<input type="hidden" name="environment_variables[][keep_existing_value]" value="true">` : ''}
<input
aria-label="Env value"
placeholder="${placeholder}"
class="input input-bordered focus:outline-offset-0 w-full"
type="text"
name="environment_variables[][value]"
<input type="hidden" name="environment_variables[][storage_type]" value="${storageType}">
<input
aria-label="Env value"
placeholder="${placeholder}"
class="input input-bordered focus:outline-offset-0 w-full"
type="text"
name="environment_variables[][value]"
value="${displayValue}"
${isHidden ? 'readonly' : ''}
>
${isHidden ? `
<button
type="button"
class="btn btn-neutral"
<button
type="button"
class="btn btn-square btn-ghost"
data-action="environment-variables#reveal"
title="Reveal value"
>
Reveal
<iconify-icon icon="lucide:eye" height="20"></iconify-icon>
</button>
` : ''}
<button
type="button"
class="btn btn-square btn-ghost ${lockColor}"
data-action="environment-variables#toggleStorageType"
title="${isSecret ? 'Secret (stored in Kubernetes Secrets)' : 'Config (stored in ConfigMap)'}"
>
<iconify-icon icon="${lockIcon}" height="20"></iconify-icon>
</button>
<button type="button" class="btn btn-danger" data-action="environment-variables#remove">Delete</button>
</div>
`;
@@ -97,6 +110,35 @@ export default class extends Controller {
}
}
toggleStorageType(event) {
event.preventDefault();
const button = event.currentTarget;
const wrapper = button.closest('[data-env-id]');
const currentType = wrapper.dataset.storageType;
const newType = currentType === 'secret' ? 'config' : 'secret';
// Update data attribute
wrapper.dataset.storageType = newType;
// Update hidden input
const hiddenInput = wrapper.querySelector('input[name="environment_variables[][storage_type]"]');
if (hiddenInput) {
hiddenInput.value = newType;
}
// Update button icon and color
const icon = button.querySelector('iconify-icon');
const isSecret = newType === 'secret';
icon.setAttribute('icon', isSecret ? 'lucide:lock' : 'lucide:lock-open');
// Update button color classes
button.classList.remove('text-warning', 'text-base-content');
button.classList.add(isSecret ? 'text-warning' : 'text-base-content');
// Update title
button.setAttribute('title', isSecret ? 'Secret (stored in Kubernetes Secrets)' : 'Config (stored in ConfigMap)');
}
async remove(event) {
event.preventDefault();
const div = event.target.closest("[data-env-id]");

View File

@@ -1,72 +1,33 @@
import { Controller } from "@hotwired/stimulus"
import AsyncSearchDropdownController from "./components/async_search_dropdown_controller"
import { renderHelmChartCard, helmChartHeader } from "../utils/helm_charts"
import { debounce } from "../utils"
export default class extends Controller {
export default class extends AsyncSearchDropdownController {
static values = {
chartName: String
}
connect() {
this.input = this.element.querySelector(`input[name="add_on[metadata][helm_chart][helm_chart.name]"]`)
// disable autocomplete
this.input.setAttribute('autocomplete', 'off')
// Create and append dropdown
this.dropdown = document.createElement('ul')
this.dropdown.className = 'hidden absolute z-10 w-full mt-1 menu bg-base-200 block rounded-box shadow-lg max-h-[300px] overflow-y-auto'
this.element.appendChild(this.dropdown)
// Bind search handler with debounce
this.input.addEventListener('input', debounce(this.performSearch.bind(this), 500));
getInputElement() {
return this.element.querySelector(`input[name="add_on[metadata][helm_chart][helm_chart.name]"]`)
}
async performSearch() {
if (!this.input.value.trim()) {
this.hideDropdown()
return
async fetchResults(query) {
const response = await fetch(`/add_ons/search?q=${encodeURIComponent(query)}`)
if (!response.ok) {
throw new Error('Failed to fetch helm charts')
}
const url = `/add_ons/search?q=${this.input.value}`
const response = await fetch(url)
const data = await response.json()
this.renderResults(data.packages)
return data.packages
}
renderResults(packages) {
if (!packages.length) {
this.hideDropdown()
return
}
this.dropdown.innerHTML = packages.map(pkg => `
<li class="p-2" data-package-name="${pkg.name}" data-package-data="${encodeURIComponent(JSON.stringify(pkg))}">
${helmChartHeader(pkg)}
</li>
`).join('')
// Add click handlers to all list items
this.dropdown.querySelectorAll('li').forEach(li => {
li.addEventListener('click', () => {
this.input.parentElement.classList.add('hidden')
this.input.value = li.dataset.packageName
const packageData = JSON.parse(decodeURIComponent(li.dataset.packageData));
this.hideDropdown()
const chartUrl = `${packageData.repository.name}/${packageData.name}`
document.querySelector(`input[name="add_on[chart_url]"]`).value = chartUrl
this.element.appendChild(renderHelmChartCard(packageData))
})
})
this.showDropdown()
renderItem(pkg) {
return helmChartHeader(pkg)
}
showDropdown() {
this.dropdown.classList.remove('hidden')
}
hideDropdown() {
this.dropdown.classList.add('hidden')
onItemSelect(pkg, itemElement) {
this.input.parentElement.classList.add('hidden')
this.input.value = pkg.name
const chartUrl = `${pkg.repository.name}/${pkg.name}`
document.querySelector(`input[name="add_on[chart_url]"]`).value = chartUrl
this.element.appendChild(renderHelmChartCard(pkg))
}
}

View File

@@ -0,0 +1,86 @@
import { Controller } from "@hotwired/stimulus"
import { EditorView, basicSetup } from "codemirror"
import { EditorState } from "@codemirror/state"
import { yaml } from "@codemirror/lang-yaml"
import { oneDark } from "@codemirror/theme-one-dark"
export default class extends Controller {
static targets = ["file", "content", "filename", "editor"]
connect() {
this.setupEditor()
}
disconnect() {
if (this.editorView) {
this.editorView.destroy()
}
}
setupEditor() {
const initialContent = this.contentTarget.value || ''
// Create the editor state with YAML syntax highlighting, dark theme, and read-only
const state = EditorState.create({
doc: initialContent,
extensions: [
basicSetup,
yaml(),
oneDark,
EditorState.readOnly.of(true),
EditorView.theme({
"&": {
fontSize: "14px",
border: "1px solid #374151",
borderRadius: "0.5rem",
height: "450px"
},
".cm-content": {
padding: "12px"
},
".cm-scroller": {
fontFamily: "'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace",
overflow: "auto"
}
})
]
})
// Create the editor view
this.editorView = new EditorView({
state,
parent: this.editorTarget
})
// Hide the original textarea
this.contentTarget.style.display = 'none'
}
selectFile(event) {
const fileButton = event.currentTarget
const manifestKey = fileButton.dataset.manifestKey
// Update active state
this.fileTargets.forEach(file => {
file.classList.remove("active")
})
fileButton.classList.add("active")
// Update content display
const content = fileButton.dataset.manifestContent
// Update CodeMirror editor
if (this.editorView) {
this.editorView.dispatch({
changes: {
from: 0,
to: this.editorView.state.doc.length,
insert: content
}
})
}
// Update filename display
this.filenameTarget.textContent = manifestKey
}
}

View File

@@ -190,7 +190,7 @@ export default class extends Controller {
const header = `
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<span class="font-medium">${serviceName}${supportsCanine ? '<span class="ml-2 mr-1">+</span><img src="/images/logo-full.png" class="inline h-8" />' : ''}</span>
<span class="font-medium">${serviceName}${supportsCanine ? '<span class="ml-2 mr-1">+</span><img src="/images/logo-full.webp" class="inline h-8" />' : ''}</span>
</div>
<div class="text-emerald-400 font-semibold total-cost">${total == 0 ? 'FREE' : `$${total}.00`}</div>
</div>

View File

@@ -0,0 +1,46 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container", "input", "slider", "cpu_requestField", "cpu_limitField", "memory_requestField", "memory_limitField"]
toggleResourceConstraints(event) {
if (event.target.checked) {
this.containerTarget.classList.remove("hidden")
} else {
this.containerTarget.classList.add("hidden")
}
}
toggleField(event) {
const fieldName = event.params.field
const isEnabled = event.target.checked
const fieldTarget = `${fieldName}FieldTarget`
if (this[fieldTarget]) {
const field = this[fieldTarget]
const numberInput = field.querySelector('input[type="number"]')
const rangeInput = field.querySelector('input[type="range"]')
if (isEnabled) {
// Enable and set default values
numberInput.removeAttribute('readonly')
numberInput.classList.remove('opacity-50', 'cursor-not-allowed')
rangeInput.disabled = false
if (fieldName.includes('cpu')) {
numberInput.value = '0.5'
rangeInput.value = '0.5'
} else if (fieldName.includes('memory')) {
numberInput.value = '128'
rangeInput.value = '128'
}
} else {
// Make readonly and set to empty so form sends nil
numberInput.setAttribute('readonly', 'readonly')
numberInput.classList.add('opacity-50', 'cursor-not-allowed')
rangeInput.disabled = true
numberInput.value = ''
}
}
}
}

View File

@@ -0,0 +1,31 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["slider", "numberInput"]
static values = {
type: String
}
updateValue(event) {
if (this.hasNumberInputTarget) {
if (this.typeValue === "float") {
this.numberInputTarget.value = parseFloat(event.target.value).toFixed(1)
} else {
this.numberInputTarget.value = parseInt(event.target.value)
}
}
}
updateSlider(event) {
if (this.hasSliderTarget) {
if (this.typeValue === "float") {
const value = parseFloat(event.target.value)
this.sliderTarget.value = value
} else {
const value = parseInt(event.target.value)
this.sliderTarget.value = value
}
}
}
}

View File

@@ -0,0 +1,21 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["tabs", "content"]
connect() {
console.log(this.contentTarget)
this.tabsTarget.addEventListener("click", (event) => {
event.preventDefault()
this.tabsTarget.querySelectorAll(".tab").forEach((radio) => {
radio.classList.remove("tab-active")
})
event.target.classList.add("tab-active")
// Show loading spinner
this.contentTarget.innerHTML = `<div class="flex items-center justify-center my-6" style="height: 300px;">
<span class="loading loading-spinner loading-sm"></span>
</div>`
this.contentTarget.src = event.target.href
})
}
}

View File

@@ -2,7 +2,7 @@ require "base64"
require "json"
class Projects::DeploymentJob < ApplicationJob
DEPLOYABLE_RESOURCES = %w[ConfigMap Deployment CronJob Service Ingress Pv Pvc]
DEPLOYABLE_RESOURCES = %w[ConfigMap Secrets Deployment CronJob Service Ingress Pv Pvc]
class DeploymentFailure < StandardError; end
def perform(deployment, user)
@@ -11,6 +11,9 @@ class Projects::DeploymentJob < ApplicationJob
project = deployment.project
connection = K8::Connection.new(project, user, allow_anonymous: true)
kubectl = create_kubectl(deployment, connection)
kubectl.register_after_apply do |yaml_content|
deployment.add_manifest(yaml_content)
end
# Create namespace
apply_namespace(project, kubectl)
@@ -18,6 +21,7 @@ class Projects::DeploymentJob < ApplicationJob
# Upload container registry secrets
upload_registry_secrets(kubectl, deployment)
apply_config_map(project, kubectl)
apply_secrets(project, kubectl)
deploy_volumes(project, kubectl)
predeploy(project, kubectl, connection)
@@ -61,7 +65,6 @@ class Projects::DeploymentJob < ApplicationJob
command.delete_if_exists!
kubectl.apply_yaml(command_yaml)
command.wait_for_completion
# Get logs f
end
def predeploy(project, kubectl, connection)

View File

@@ -3,7 +3,7 @@ class Scheduled::FetchMetricsJob < ApplicationJob
def perform
Cluster.running.each do |cluster|
connection = K8::Connection.new(cluster, User.new)
connection = K8::Connection.new(cluster, nil, allow_anonymous: true)
nodes = K8::Metrics::Metrics.call(connection)
rescue => e
Rails.logger.error("Error fetching metrics for cluster #{cluster.name}: #{e.message}")

View File

@@ -2,15 +2,18 @@
#
# Table name: build_configurations
#
# id :bigint not null, primary key
# build_type :integer default(0), not null
# driver :integer not null
# image_repository :string not null
# created_at :datetime not null
# updated_at :datetime not null
# build_cloud_id :bigint
# project_id :bigint not null
# provider_id :bigint not null
# id :bigint not null, primary key
# build_type :integer not null
# buildpack_base_builder :string
# context_directory :string default("./"), not null
# dockerfile_path :string default("./Dockerfile"), not null
# driver :integer not null
# image_repository :string not null
# created_at :datetime not null
# updated_at :datetime not null
# build_cloud_id :bigint
# project_id :bigint not null
# provider_id :bigint not null
#
# Indexes
#
@@ -36,6 +39,7 @@ class BuildConfiguration < ApplicationRecord
belongs_to :project
belongs_to :build_cloud, optional: true
belongs_to :provider
has_many :build_packs, -> { order(:build_order) }, dependent: :destroy
validates_presence_of :project, :provider, :driver
validates_presence_of :image_repository
@@ -45,9 +49,19 @@ class BuildConfiguration < ApplicationRecord
}
def self.permit_params(params)
params.permit(:image_repository, :driver, :build_cloud_id, :provider_id)
params.permit(:image_repository, :driver, :build_cloud_id, :provider_id, :context_directory, :dockerfile_path, :build_type, :buildpack_base_builder)
end
def self.available_buildpacks
packs_file = Rails.root.join("resources", "build_packs", "packs.yaml")
YAML.load_file(packs_file)
end
enum :build_type, {
dockerfile: 0,
buildpacks: 1
}
enum :driver, {
cloud: 0,
docker: 1,

82
app/models/build_pack.rb Normal file
View File

@@ -0,0 +1,82 @@
# == Schema Information
#
# Table name: build_packs
#
# id :bigint not null, primary key
# build_order :integer not null
# details :jsonb
# name :string
# namespace :string
# reference_type :integer not null
# uri :text
# version :string
# created_at :datetime not null
# updated_at :datetime not null
# build_configuration_id :bigint not null
#
# Indexes
#
# index_build_packs_on_build_configuration_id (build_configuration_id)
# index_build_packs_on_config_type_namespace_name (build_configuration_id,reference_type,namespace,name)
# index_build_packs_on_config_uri (build_configuration_id,uri)
#
# Foreign Keys
#
# fk_rails_... (build_configuration_id => build_configurations.id)
#
class BuildPack < ApplicationRecord
VERIFIED_NAMESPACES = %w[io.buildpacks paketo-buildpacks heroku tanzu-buildpacks].freeze
belongs_to :build_configuration
validates_presence_of :build_order
enum :reference_type, {
registry: 0,
git: 1,
url: 2
}
validates :reference_type, presence: true
validates :namespace, presence: true, if: :registry?
validates :name, presence: true, if: :registry?
validates :uri, presence: true, unless: :registry?
# Helper method to get full buildpack reference for pack CLI
def reference
case reference_type.to_sym
when :registry
if version.present?
"#{namespace}/#{name}:#{version}"
else
"#{namespace}/#{name}"
end
else
uri
end
end
# Synthetic property to check if buildpack is from a verified namespace
def verified?
registry? && VERIFIED_NAMESPACES.include?(namespace)
end
# Display name for UI
def display_name
if registry?
"#{namespace}/#{name}"
elsif git? && uri.present?
# Extract repo name from git URL
uri.split('/').last.to_s.gsub('.git', '')
else
uri
end
end
def key
"#{namespace}/#{name}"
end
def static_info
BuildConfiguration.available_buildpacks[key] || {}
end
end

View File

@@ -3,6 +3,7 @@
# Table name: deployments
#
# id :bigint not null, primary key
# manifests :jsonb
# status :integer default("in_progress"), not null
# created_at :datetime not null
# updated_at :datetime not null
@@ -24,4 +25,19 @@ class Deployment < ApplicationRecord
after_update_commit do
self.build.broadcast_build
end
def add_manifest(yaml)
manifest = YAML.safe_load(yaml)
kind = manifest["kind"]&.downcase
name = manifest.dig("metadata", "name")
manifest_key = "#{kind}/#{name}"
self.manifests ||= {}
self.manifests[manifest_key] = yaml
save!
end
def has_manifests?
manifests.keys.any?
end
end

View File

@@ -2,12 +2,13 @@
#
# 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
# id :bigint not null, primary key
# name :string not null
# storage_type :integer default("config"), not null
# value :text
# created_at :datetime not null
# updated_at :datetime not null
# project_id :bigint not null
#
# Indexes
#
@@ -23,6 +24,8 @@ class EnvironmentVariable < ApplicationRecord
belongs_to :project
enum :storage_type, { config: 0, secret: 1 }
validates :name, presence: true,
uniqueness: { scope: :project_id },
format: {
@@ -35,12 +38,17 @@ class EnvironmentVariable < ApplicationRecord
message: "cannot contain special characters that might enable command injection"
}
before_save :strip_whitespace
before_validation :strip_whitespace
def base64_encoded_value
return nil unless value.present?
Base64.strict_encode64(value)
end
private
def strip_whitespace
self.name = name.strip.upcase
self.value = value.strip
self.name = name.strip.upcase if name.present?
self.value = value.strip if value.present?
end
end

View File

@@ -8,7 +8,6 @@
# canine_config :jsonb
# container_registry_url :string
# docker_build_context_directory :string default("."), not null
# docker_command :string
# dockerfile_path :string default("./Dockerfile"), not null
# name :string not null
# postdeploy_command :text

View File

@@ -0,0 +1,50 @@
# == Schema Information
#
# Table name: resource_constraints
#
# id :bigint not null, primary key
# cpu_limit :bigint
# cpu_request :bigint
# gpu_request :integer
# memory_limit :bigint
# memory_request :bigint
# created_at :datetime not null
# updated_at :datetime not null
# service_id :bigint not null
#
# Indexes
#
# index_resource_constraints_on_service_id (service_id)
#
class ResourceConstraint < ApplicationRecord
include StorageHelper
belongs_to :service
validates :cpu_request, numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
validates :cpu_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
validates :memory_request, numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
validates :memory_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
validates :gpu_request, numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
# Formatted getters for Kubernetes YAML templates
def cpu_request_formatted
return nil if cpu_request.nil?
integer_to_compute(cpu_request)
end
def cpu_limit_formatted
return nil if cpu_limit.nil?
integer_to_compute(cpu_limit)
end
def memory_request_formatted
return nil if memory_request.nil?
integer_to_memory(memory_request)
end
def memory_limit_formatted
return nil if memory_limit.nil?
integer_to_memory(memory_limit)
end
end

View File

@@ -10,6 +10,7 @@
# healthcheck_url :string
# last_health_checked_at :datetime
# name :string not null
# pod_yaml :jsonb
# replicas :integer default(1)
# service_type :integer not null
# status :integer default("pending")
@@ -42,6 +43,8 @@ class Service < ApplicationRecord
scope :running, -> { where(status: [ :healthy, :unhealthy, :updated ]) }
has_one :cron_schedule, dependent: :destroy
has_one :resource_constraint, dependent: :destroy
validates :cron_schedule, presence: true, if: :cron_job?
validates :command, presence: true, if: :cron_job?
has_many :domains, dependent: :destroy
@@ -65,7 +68,7 @@ class Service < ApplicationRecord
end
def self.permitted_params(params)
params.require(:service).permit(
permitted = params.require(:service).permit(
:service_type,
:command,
:name,
@@ -74,6 +77,19 @@ class Service < ApplicationRecord
:replicas,
:description,
:allow_public_networking,
:pod_yaml
)
# Convert YAML text to JSON if pod_yaml is present
if permitted[:pod_yaml].present?
begin
permitted[:pod_yaml] = YAML.safe_load(permitted[:pod_yaml])
rescue Psych::SyntaxError => e
# If YAML parsing fails, keep the original value so validation can catch it
Rails.logger.error("Failed to parse pod_yaml: #{e.message}")
end
end
permitted
end
end

View File

@@ -42,7 +42,7 @@ module Builders
command += [ "--push" ] # Push directly to registry
command += [ "--progress", "plain" ]
command += [ "-t", project.container_image_reference ]
command += [ "-f", File.join(repository_path, project.dockerfile_path) ]
command += [ "-f", File.join(repository_path, project.build_configuration.dockerfile_path) ]
# Add build arguments
project.environment_variables.each do |envar|
@@ -56,7 +56,7 @@ module Builders
command += [ "--push" ]
# Add build context
command << File.join(repository_path, project.docker_build_context_directory)
command << File.join(repository_path, project.build_configuration.context_directory)
command
end

View File

@@ -6,46 +6,18 @@ module Builders
class Docker < Builders::Base
# Build and push the Docker image
def build_image(repository_path)
execute_docker_build(repository_path)
if project.build_configuration.buildpacks?
Builders::Frontends::BuildpackBuilder.new(build).build_with_buildpacks(repository_path)
else
Builders::Frontends::DockerfileBuilder.new(build).build_with_dockerfile(repository_path)
end
end
private
def execute_docker_build(repository_path)
docker_build_command = construct_buildx_command(repository_path)
# Create a new instance of RunAndLog with the build object as the loggable and killable
runner = Cli::RunAndLog.new(build, killable: build)
# Call the runner with the command (joined as a string since RunAndLog expects a string)
runner.call(docker_build_command.join(" "))
rescue Cli::CommandFailedError => e
raise "Docker build failed: #{e.message}"
end
def construct_buildx_command(repository_path)
docker_build_command = [
"docker",
"--context=default",
"buildx",
"build",
"--progress=plain",
"--platform", "linux/amd64",
"-t", project.container_image_reference,
"-f", File.join(repository_path, project.dockerfile_path)
]
# Add environment variables to the build command
project.environment_variables.each do |envar|
docker_build_command.push("--build-arg", "#{envar.name}=\"#{envar.value}\"")
end
docker_build_command.push("--push")
# Add the build context directory at the end
docker_build_command.push(File.join(repository_path, project.docker_build_context_directory))
Rails.logger.info("Docker build command: `#{docker_build_command.join(" ")}`")
docker_build_command
# Pack publishes during build with --publish flag
def publish_during_build?
true
end
end
end

View File

@@ -0,0 +1,86 @@
# frozen_string_literal: true
class Builders::Frontends::BuildpackBuilder
attr_accessor :build, :project
def initialize(build)
@build = build
@project = build.project
end
# Build image using Cloud Native Buildpacks
def build_with_buildpacks(repository_path)
build_config = build.project.build_configuration
build.info("Building with Cloud Native Buildpacks", color: :blue)
build.info("Builder: #{build_config.buildpack_base_builder}", color: :cyan)
build.info("Context: #{build_config.context_directory}", color: :cyan)
# Log buildpacks in order
if build_config.build_packs.any?
build.info("Buildpacks:", color: :cyan)
build_config.build_packs.each do |pack|
verified_badge = pack.verified? ? " [verified]" : ""
build.info(" #{pack.build_order + 1}. #{pack.reference}#{verified_badge}")
end
else
build.info("No buildpacks specified - builder will auto-detect", color: :yellow)
end
# Generate and execute pack command
command = generate_pack_command(repository_path, build_config)
build.info("Running pack build...", color: :green)
run_pack_command(command)
# Push image if not published during build
push_image_after_build unless publish_during_build?
end
private
def generate_pack_command(repository_path, build_config)
image_name = build_config.container_image_reference
context_path = File.join(repository_path, build_config.context_directory)
command = [
"pack", "build", image_name,
"--builder", build_config.buildpack_base_builder,
"--path", context_path
]
# Add buildpacks in order
build_config.build_packs.each do |pack|
command += [ "--buildpack", pack.reference ]
end
# Add publish flag if supported by driver
command << "--publish" if publish_during_build?
# Add pull policy to always pull latest builder
command += [ "--pull-policy", "always" ]
# Trust builder (required for some builders)
command << "--trust-builder"
command.shelljoin
end
def run_pack_command(command)
runner = Cli::RunAndLog.new(build, killable: build)
runner.call(command)
rescue Cli::CommandFailedError => e
raise Projects::BuildJob::BuildFailure, "Pack build failed: #{e.message}"
end
# Override in including class if push happens during build
def publish_during_build?
false
end
# Override in including class to implement image push logic
def push_image_after_build
# Default: assume publish_during_build? is true
# Concrete builders should override if they need separate push
end
end

View File

@@ -0,0 +1,45 @@
class Builders::Frontends::DockerfileBuilder
attr_accessor :build, :project
def initialize(build)
@build = build
@project = build.project
end
def build_with_dockerfile(repository_path)
docker_build_command = construct_buildx_command(repository_path)
# Create a new instance of RunAndLog with the build object as the loggable and killable
runner = Cli::RunAndLog.new(build, killable: build)
# Call the runner with the command (joined as a string since RunAndLog expects a string)
runner.call(docker_build_command.join(" "))
rescue Cli::CommandFailedError => e
raise "Docker build failed: #{e.message}"
end
def construct_buildx_command(repository_path)
docker_build_command = [
"docker",
"--context=default",
"buildx",
"build",
"--progress=plain",
"--platform", "linux/amd64",
"-t", project.container_image_reference,
"-f", File.join(repository_path, project.build_configuration.dockerfile_path)
]
# Add environment variables to the build command
project.environment_variables.each do |envar|
docker_build_command.push("--build-arg", "#{envar.name}=\"#{envar.value}\"")
end
docker_build_command.push("--push")
# Add the build context directory at the end
docker_build_command.push(File.join(repository_path, project.build_configuration.context_directory))
Rails.logger.info("Docker build command: `#{docker_build_command.join(" ")}`")
docker_build_command
end
end

View File

@@ -64,6 +64,8 @@ class Git::Github::Client < Git::Client
def webhook_exists?
webhook.present?
rescue Octokit::NotFound
false
end
def remove_webhook!

View File

@@ -25,7 +25,8 @@ class K8::Base
def to_yaml
template_content = template_path.read
erb_template = ERB.new(template_content)
erb_template.result(binding)
result = erb_template.result(binding)
result.gsub(/\n\s*\n/, "\n")
end
def client

View File

@@ -11,6 +11,11 @@ class K8::Kubectl
raise "Kubeconfig is required"
end
@runner = runner
@after_apply_blocks = []
end
def register_after_apply(&block)
@after_apply_blocks << block
end
def apply_yaml(yaml_content)
@@ -25,6 +30,10 @@ class K8::Kubectl
runner.call(command, envs: { "KUBECONFIG" => kubeconfig_file.path })
end
end
@after_apply_blocks.each do |block|
block.call(yaml_content)
end
end
def call(command)

View File

@@ -14,9 +14,12 @@ class K8::Stateless::Ingress < K8::Base
def certificate_status
return nil unless @service.domains.any?
return nil unless @service.allow_public_networking?
return nil unless @service.allow_public_networking?
kubectl.call("get certificate example-tls -n #{@project.name} -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}'") == "True"
kubectl.call("get certificate #{certificate_name} -n #{@project.name} -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}'") == "True"
end
def certificate_name
"#{@service.name}-tls"
end
def get_ingress

View File

@@ -0,0 +1,8 @@
class K8::Stateless::Secrets < K8::Base
attr_reader :project
delegate :name, to: :project
def initialize(project)
@project = project
end
end

View File

@@ -38,12 +38,7 @@
</div>
</div>
<% if @pagy.pages > 1 %>
<div class="flex items-center justify-end px-5 pb-5 pt-3">
<%== custom_pagy_nav(@pagy) %>
</div>
<% end %>
<%= render 'shared/pagination', pagy: @pagy %>
</div>
<!-- Start: Click Outside Modal -->

View File

@@ -35,6 +35,7 @@
Changes to the values.yaml will be applied to the chart immediately.
</div>
</div>
<div>
<h2 class="text-2xl font-bold">Danger zone</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />

View File

@@ -29,9 +29,5 @@
</div>
</div>
<% if @pagy.pages > 1 %>
<div class="flex items-center justify-end px-5 pb-5 pt-3">
<%== custom_pagy_nav(@pagy) %>
</div>
<% end %>
<%= render 'shared/pagination', pagy: @pagy %>
</div>

View File

@@ -1,3 +1,9 @@
<%= add_on_layout(@add_on) do %>
<%= render "log_outputs/pod_logs", logs: @logs, pod_events: @pod_events, back_path: add_on_processes_path(@add_on) %>
<%= render(
"log_outputs/pod_logs",
logs: @logs,
pod_events: @pod_events,
back_path: add_on_processes_path(@add_on),
error: @error
) %>
<% end %>

View File

@@ -0,0 +1,7 @@
<div class="relative" data-controller="buildpack-search">
<input
type="text"
placeholder="Search for buildpacks (e.g., ruby, node, python)..."
class="input input-bordered w-full"
/>
</div>

View File

@@ -14,7 +14,7 @@
"error"
end
%>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
<%= cluster.status %>
</div>

View File

@@ -7,25 +7,25 @@
<hr class="mt-3 mb-4 border-t border-base-300" />
<%= render "edit_form", cluster: @cluster %>
<div class="mt-6" data-controller="kubeconfig-editor">
<div class="mt-6" data-controller="content-toggle">
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold">Credentials</h2>
<button type="button"
<button type="button"
class="btn btn-sm btn-outline"
data-action="click->kubeconfig-editor#toggleEdit"
data-kubeconfig-editor-target="editButton">
data-action="click->content-toggle#toggleEdit"
data-content-toggle-target="editButton">
<iconify-icon icon="lucide:pen" height="16"></iconify-icon>
Edit
</button>
</div>
<hr class="mt-3 mb-4 border-t border-base-300" />
<div data-kubeconfig-editor-target="placeholder" class="p-4 bg-base-200 rounded-lg">
<div data-content-toggle-target="placeholder" class="p-4 bg-base-200 rounded-lg">
<p class="text-sm text-gray-500">Kubeconfig is hidden for security. Click edit to view and modify.</p>
</div>
<div data-kubeconfig-editor-target="editorContainer" class="hidden">
<div data-content-toggle-target="editorContainer" class="hidden">
<%= form_with(model: @cluster, url: cluster_path(@cluster), method: :patch) do |form| %>
<%= form.hidden_field :kubeconfig_yaml_format, value: "true" %>
<div class="form-group" data-controller="yaml-editor">
@@ -40,9 +40,9 @@
</div>
<div class="form-footer flex gap-2">
<%= form.submit "Save", class: "btn btn-primary" %>
<button type="button"
<button type="button"
class="btn btn-outline"
data-action="click->kubeconfig-editor#cancelEdit">
data-action="click->content-toggle#cancelEdit">
Cancel
</button>
</div>

View File

@@ -40,9 +40,5 @@
</div>
</div>
<% if @pagy.pages > 1 %>
<div class="flex items-center justify-end px-5 pb-5 pt-3">
<%== custom_pagy_nav(@pagy) %>
</div>
<% end %>
<%= render 'shared/pagination', pagy: @pagy %>
</div>

View File

@@ -3,11 +3,11 @@
<div>2. Create a new <a href="https://cloud.digitalocean.com/kubernetes/clusters/new" target="_blank">Kubernetes cluster</a></div>
<div class="ml-6">
<a href="https://cloud.digitalocean.com/kubernetes/clusters/new" target="_blank">
<img src="/images/instructions/digitalocean-create-button.png" class="rounded-3xl w-[300px]" />
<img src="/images/instructions/digitalocean-create-button.webp" class="rounded-3xl w-[300px]" />
</a>
</div>
<div>3. Download the kubeconfig and upload it below</div>
<div class="ml-6">
<img src="/images/instructions/digitalocean-download-button.png" class="rounded-3xl w-[300px]" />
<img src="/images/instructions/digitalocean-download-button.webp" class="rounded-3xl w-[300px]" />
</div>
</div>

View File

@@ -3,11 +3,11 @@
<div>2. Create a new <a href="https://cloud.linode.com/kubernetes/create" target="_blank">Kubernetes cluster</a></div>
<div class="ml-6">
<a href="https://cloud.linode.com/kubernetes/create" target="_blank">
<img src="/images/instructions/linode-create-button.png" class="rounded-3xl w-[300px]" />
<img src="/images/instructions/linode-create-button.webp" class="rounded-3xl w-[300px]" />
</a>
</div>
<div>3. Download the Kubeconfig and upload it below</div>
<div class="ml-6">
<img src="/images/instructions/linode-download-button.png" class="rounded-3xl w-[300px]" />
<img src="/images/instructions/linode-download-button.webp" class="rounded-3xl w-[300px]" />
</div>
</div>

View File

@@ -2,8 +2,8 @@
<div class="w-full max-w-2xl">
<% if @accounts.empty? %>
<div class="text-center py-12">
<div class="mb-6">
<iconify-icon icon="lucide:user-plus" width="64" height="64" class="text-base-content/30"></iconify-icon>
<div class="mb-10">
<img src="/images/illustrations/rocket-launch.webp" alt="Rocket Launch" class="w-[300px] mx-auto" />
</div>
<h3 class="text-xl font-semibold text-base-content mb-2">No Accounts</h3>
<p class="text-base-content/70 mb-6">Get started on Canine by creating your first account.</p>

View File

@@ -24,7 +24,7 @@
<div class="mt-6 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-2">Public Git Repository</h3>
<p class="mb-4">Use a public repository by entering the URL below. Features like PR Previews and Auto-Deploy are not available if the repository has not been configured for Render.</p>
<p class="mb-4">Use a public repository by entering the URL below. Features like PR Previews and Auto-Deploy are not available if the repository has not been configured for Canine.</p>
<div class="flex gap-4">
<input type="text" placeholder="czhu12/whiteboarder" data-github-select-repository-target="publicRepository" class="input input-bordered w-full mb-4" />
<button type="button" data-action="github-select-repository#selectPublicRepository" class="btn btn-outline">Continue</button>

View File

@@ -1,7 +1,7 @@
<div class="leftmenu-wrapper z-50">
<%= link_to root_path, class: "flex h-16 items-center justify-center" do %>
<img src="/images/logo-compact.png" class="w-[40px] h-[40px] lg:hidden" />
<img src="/images/logo-full.png" class="h-[40px] hidden lg:block" />
<img src="/images/logo-compact.webp" class="w-[40px] h-[40px] lg:hidden" />
<img src="/images/logo-full.webp" class="h-[40px] hidden lg:block" />
<span class="sr-only"><%= Rails.configuration.application_name %></span>
<% end %>
@@ -75,7 +75,7 @@
<ul>
<% current_account.projects.order(created_at: :desc).each do |project| %>
<li>
<%= link_to root_projects_path(project), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?("/projects/#{project.id}")}" do %>
<%= link_to root_projects_path(project), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?(project_path(project))}" do %>
<div class="flex items-center gap-2">
<%= project.name %>
</div>
@@ -106,7 +106,7 @@
<ul>
<% current_account.clusters.order(created_at: :desc).each do |cluster| %>
<li>
<%= link_to cluster_path(cluster), class: "hover:bg-base-content/15 #{'active' if current_page?(cluster_path(cluster))}" do %>
<%= link_to cluster_path(cluster), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?(cluster_path(cluster))}" do %>
<div class="flex items-center gap-2">
<%= cluster.name %>
</div>
@@ -137,7 +137,7 @@
<ul>
<% current_account.add_ons.order(created_at: :desc).each do |add_on| %>
<li>
<%= link_to add_on_path(add_on), class: "hover:bg-base-content/15 #{'active' if current_page?(add_on_path(add_on))}" do %>
<%= link_to add_on_path(add_on), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?(add_on_path(add_on))}" do %>
<div class="flex items-center gap-2">
<%= add_on.name %>
</div>

View File

@@ -14,7 +14,7 @@
<%= render 'shared/navbar' %>
<%= render 'shared/notices' %>
<div class="content-wrapper">
<div class="content-wrapper container">
<%= yield %>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<div class="min-h-screen py-8">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Header and Navigation -->
<img src="/images/logo-full.png" alt="Canine Logo" class="h-[40px]">
<img src="/images/logo-full.webp" alt="Canine Logo" class="h-[40px]">
<div class="mb-8">
<h1 class="text-3xl font-bold ">
Select your installation method
@@ -20,7 +20,7 @@
<div class="form-control mt-4">
<%= render "shared/partials/radio_selector", selected: nil, options: [
{
icon: "/images/logo-compact.png",
icon: "/images/logo-compact.webp",
name: "project[build_configuration][driver]",
label: "Normal (Recommended)",
value: "normal",
@@ -28,7 +28,7 @@
href: new_user_registration_url
},
{
icon: "/images/helm/portainer.png",
icon: "/images/helm/portainer.webp",
name: "project[build_configuration][driver]",
label: "Portainer",
description: "Import from an existing Portainer instance. Only select this if you want to integrate Canine with Portainer.",

View File

@@ -6,6 +6,12 @@
<h4 class="text-md font-medium"><%= params[:id] %></h4>
<div class="badge badge-success">• LIVE</div>
</div>
<% if defined?(error) && error.present? %>
<div class="alert alert-warning my-5">
<iconify-icon icon="lucide:alert-triangle" height="20"></iconify-icon>
<span><%= error %></span>
</div>
<% end %>
<div class="bg-gray-900 text-gray-100 rounded-lg shadow-lg">
<div class="overflow-auto h-96 bg-gray-800 p-2 rounded" data-logs-target="container">
<pre class="text-sm font-mono whitespace-pre-wrap" id="logs"><%= ansi_to_tailwind(logs.force_encoding("UTF-8")).html_safe || "No logs yet..." %></pre>

View File

@@ -0,0 +1,87 @@
<div class="flex flex-row gap-6">
<div class="flex-shrink my-4 border-l-2 border-base-300 pl-4">
</div>
<div data-controller="buildpack-fields" data-buildpack-fields-packs-value="<%= BuildConfiguration.available_buildpacks.to_json %>">
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Base Builder</span>
</label>
<%= bc_form.select(
:buildpack_base_builder,
options_for_select(
[
["heroku/builder-classic:22", "heroku/builder-classic:22"],
["heroku/builder:22", "heroku/builder:22"],
["heroku/builder:24", "heroku/builder:24"],
["heroku/buildpacks:18", "heroku/buildpacks:18"],
["heroku/buildpacks:20", "heroku/buildpacks:20"],
["paketobuildpacks/builder-jammy-full:latest", "paketobuildpacks/builder-jammy-full:latest"],
["paketobuildpacks/builder:full", "paketobuildpacks/builder:full"]
],
selected: build_configuration.buildpack_base_builder || "heroku/buildpacks:24"
),
{ include_blank: "Select a base builder..." },
{ class: "select select-bordered w-full", data: { buildpack_fields_target: "baseBuilder" } }
) %>
<label class="label">
<span class="label-text-alt">Select the base builder image for Cloud Native Buildpacks</span>
</label>
</div>
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Buildpacks</span>
</label>
<div data-buildpack-fields-target="list" class="space-y-2">
<% build_configuration.build_packs.each do |build_pack| %>
<%= render "projects/build_configurations/buildpack_item", build_pack: build_pack %>
<% end %>
</div>
<button type="button" class="btn btn-outline btn-primary mt-2" data-action="click->buildpack-fields#openModal">
<iconify-icon icon="mdi:plus" width="20" height="20"></iconify-icon>
Add Buildpack
</button>
</div>
<template data-buildpack-fields-target="template">
<%= render "projects/build_configurations/buildpack_item" %>
</template>
<dialog data-buildpack-fields-target="modal" class="modal">
<div class="modal-box w-11/12 max-w-5xl">
<h3 class="font-bold text-lg">Add Buildpacks</h3>
<p class="py-2 text-sm text-gray-600">Select buildpacks to add to your build configuration.</p>
<div class="space-y-4 mt-4">
<div>
<h4 class="font-semibold mb-2">Selected Buildpacks</h4>
<div data-buildpack-fields-target="selectedBuildpacks" class="border border-base-300 rounded-lg max-h-48 overflow-y-auto">
<div class="text-sm text-gray-500 p-4 text-center">No buildpacks selected</div>
</div>
</div>
<div>
<h4 class="font-semibold mb-2">Official Buildpacks</h4>
<div data-buildpack-fields-target="availableBuildpacks" class="border border-base-300 rounded-lg max-h-64 overflow-y-auto">
<!-- Official buildpacks will be rendered here -->
</div>
</div>
<div>
<h4 class="font-semibold mb-2">Search Registry</h4>
<%= render "build_packs/search" %>
<div data-buildpack-fields-target="searchResults" class="border border-base-300 rounded-lg max-h-64 overflow-y-auto mt-2">
<!-- Search results will be rendered here -->
</div>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" data-action="click->buildpack-fields#closeModal">Cancel</button>
<button type="button" class="btn btn-primary" data-action="click->buildpack-fields#addSelectedBuildpacks">Add Selected</button>
</div>
</div>
</dialog>
</div>
</div>

View File

@@ -0,0 +1,19 @@
<% build_pack = local_assigns[:build_pack] || BuildPack.new %>
<div class="card bg-base-200 p-4">
<div class="flex gap-3 items-center">
<div class="drag-handle cursor-move flex items-center">
<iconify-icon icon="mdi:drag-vertical" width="20" height="20" class="text-gray-500"></iconify-icon>
</div>
<img data-template-image class="w-10 h-10 object-contain" src="<%= build_pack.static_info['image'] || '/images/languages/buildpack.webp' %>" />
<div class="flex-1">
<div class="font-medium" data-template-title><%= "#{build_pack.namespace}/#{build_pack.name}" %></div>
<div class="text-sm text-gray-600" data-template-description><%= build_pack.details.dig('latest', 'description') %></div>
</div>
<button type="button" class="btn btn-sm btn-ghost" data-action="click->buildpack-fields#remove">
<iconify-icon icon="mdi:close" width="20" height="20"></iconify-icon>
</button>
</div>
<input type="hidden" data-template-namespace name="project[build_configuration][build_packs_attributes][][namespace]" value="<%= build_pack.namespace %>" />
<input type="hidden" data-template-name name="project[build_configuration][build_packs_attributes][][name]" value="<%= build_pack.name %>" />
<input type="hidden" data-template-reference-type name="project[build_configuration][build_packs_attributes][][reference_type]" value="<%= build_pack.reference_type %>" />
</div>

View File

@@ -0,0 +1,19 @@
<div class="flex flex-row gap-6">
<div class="flex-shrink my-4 border-l-2 border-base-300 pl-4">
</div>
<div class="w-full">
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Dockerfile path</span>
</label>
<%= bc_form.text_field(
:dockerfile_path,
class: "input input-bordered w-full focus:outline-offset-0",
value: build_configuration.dockerfile_path
) %>
<label class="label">
<span class="label-text-alt">* Required</span>
</label>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<div>
<div>
<% build_configuration = project.build_configuration || BuildConfiguration.new(driver: 'docker') %>
<% build_configuration = project.build_configuration || BuildConfiguration.new %>
<%= form.fields_for :build_configuration, build_configuration do |bc_form| %>
<div class="form-control mt-4">
<%= render "shared/partials/radio_selector", selected: build_configuration.driver, options: [
@@ -12,7 +12,7 @@
description: "Build images using Docker on the deployment server. Simple setup, suitable for smaller projects."
},
{
icon: "/images/logo-compact.png",
icon: "/images/logo-compact.webp",
name: "project[build_configuration][driver]",
label: "Canine Cloud",
value: "cloud",
@@ -30,7 +30,22 @@
].select { |option| BuildConfiguration::BUILDER_OPTIONS.include?(option[:value].to_sym) } %>
</div>
<div class="form-control mt-1 mb-2 w-full max-w-sm">
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Build context directory</span>
</label>
<%= bc_form.text_field(
:context_directory,
class: "input input-bordered w-full focus:outline-offset-0",
value: build_configuration.context_directory
) %>
<label class="label">
<span class="label-text-alt">Where should we run the build? Defaults to the root of the repository.</span>
</label>
</div>
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Credentials</span>
</label>
@@ -49,10 +64,10 @@
{ class: "select select-bordered w-full" }
) %>
<label class="label">
<span class="label-text-alt">Select a provider with a container registry</span>
<span class="label-text-alt">Select a credential that gives access to a container registry</span>
</label>
</div>
<div class="form-control mt-1 mb-2 w-full max-w-sm">
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Image repository</span>
</label>
@@ -64,6 +79,34 @@
pattern: "[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]/[a-zA-Z0-9._-]+",
title: "Must be in the format 'namespace/repo'"
) %>
<label class="label">
<span class="label-text-alt">If this is left blank, a container registry will be automatically created for you.</span>
</label>
</div>
<div class="form-control mt-4">
<label class="label mb-2">
<span class="label-text">Build method</span>
</label>
<%= render "shared/partials/radio_selector", selected: build_configuration.build_type || "dockerfile", options: [
{
icon: "skill-icons:docker",
name: "project[build_configuration][build_type]",
label: "Dockerfile",
value: "dockerfile",
description: "Build images using a Dockerfile. Traditional Docker build approach with full control over the build process.",
partial: "projects/build_configurations/dockerfile_fields",
locals: { bc_form:, build_configuration: }
},
{
icon: "devicon:heroku",
name: "project[build_configuration][build_type]",
label: "Buildpacks",
value: "buildpacks",
description: "Use Cloud Native Buildpacks to automatically detect and build your application. No Dockerfile needed.",
partial: "projects/build_configurations/buildpack_fields",
locals: { bc_form:, build_configuration: }
}
] %>
</div>
<% end %>
</div>

View File

@@ -49,16 +49,6 @@
) %>
<% end %>
<%= render(FormFieldComponent.new(
label: "Container command",
description: "The command to run to start the container."
)) do %>
<%= form.text_field :docker_command, class: "input input-bordered w-full focus:outline-offset-0" %>
<label class="label">
<span class="label-text-alt">If this is left blank, the default run command in the Dockerfile will be used.</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Predeploy command",
description: "The command to run before deploying the project. This is useful for running migrations or other setup commands."

View File

@@ -2,7 +2,7 @@
<%= form_with(model: project, data: { turbo: false }) do |form| %>
<h2 class="text-2xl font-bold">Create a new project from Git repository</h2>
<%= link_to(
"Deploy from Docker Hub instead →",
"Deploy from Container Registry instead →",
new_project_path(provider_type: Provider::REGISTRY_TYPE),
class: "inline-block mt-2 underline underline-offset-4 text-blue-300 hover:text-blue-200 text-sm",
) %>
@@ -80,16 +80,6 @@
<span class="label-text-alt">* Required</span>
</label>
</div>
<div class="form-control mt-1 mb-2 w-full max-w-sm">
<label class="label">
<span class="label-text">Container registry URL</span>
</label>
<%= form.text_field :container_registry_url, class: "input input-bordered w-full focus:outline-offset-0", value: "" %>
<label class="label">
<span class="label-text-alt">If this is left blank, <span data-new-project-target="gitProviderLabel">Github</span> Container Registry will be used</span>
</label>
</div>
</div>
<% end %>
@@ -100,33 +90,6 @@
<%= form.check_box :autodeploy, class: "checkbox" %>
<% end %>
<%= render(FormFieldComponent.new(
label: "Dockerfile path",
description: "The path to the Dockerfile in your repository."
)) do %>
<%= form.text_field :dockerfile_path, class: "input input-bordered w-full focus:outline-offset-0" %>
<label class="label">
<span class="label-text-alt">* Required</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Docker build context directory",
description: "The directory to use as the build context for the Docker build."
)) do %>
<%= form.text_field :docker_build_context_directory, class: "input input-bordered w-full focus:outline-offset-0" %>
<% end %>
<%= render(FormFieldComponent.new(
label: "Docker command",
description: "The command to run to start the container."
)) do %>
<%= form.text_field :docker_command, class: "input input-bordered w-full focus:outline-offset-0" %>
<label class="label">
<span class="label-text-alt">If this is left blank, the default run command in the Dockerfile will be used</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Predeploy command",
description: "The command to run before deploying the project. This is useful for running migrations or other setup commands."

View File

@@ -1,6 +1,6 @@
<tr id="<%= dom_id(event.eventable, :index) %>" class="cursor-pointer hover:bg-base-200/40">
<td>
<div class="flex items-center space-x-3 max-w-[150px] md:max-w-[250px]">
<div class="flex items-center space-x-3 max-w-[150px] md:max-w-[250px] lg:max-w-[350px] xl:max-w-[450px]">
<div class="font-medium truncate">
<%= event.eventable.commit_message %>
</div>

View File

@@ -0,0 +1,45 @@
<div data-controller="manifest-browser" class="grid grid-cols-12 gap-4">
<!-- Left Column: File List -->
<div class="col-span-3 flex flex-col">
<div class="px-4 py-3">
<h3 class="text-sm font-bold text-base-content/70">Files</h3>
</div>
<div class="border border-base-300 rounded-lg overflow-y-auto" style="height: 450px;">
<div class="p-4">
<ul class="menu menu-sm p-0 gap-1">
<% deployment.manifests.keys.each_with_index do |manifest_key, index| %>
<li>
<button
type="button"
data-manifest-browser-target="file"
data-manifest-key="<%= manifest_key %>"
data-manifest-content="<%= ERB::Util.html_escape(deployment.manifests[manifest_key]) %>"
data-action="click->manifest-browser#selectFile"
class="<%= 'active' if index == 0 %>"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span class="truncate"><%= manifest_key %></span>
</button>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<!-- Right Column: Content Display -->
<div class="col-span-9 flex flex-col">
<div class="px-4 py-3">
<h3 class="text-sm font-bold text-base-content/70" data-manifest-browser-target="filename"><%= deployment.manifests.keys.first %></h3>
</div>
<div>
<textarea
data-manifest-browser-target="content"
class="hidden"
><%= deployment.manifests.values.first %></textarea>
<div data-manifest-browser-target="editor" style="height: 450px;"></div>
</div>
</div>
</div>

View File

@@ -42,5 +42,7 @@
<% end %>
</tbody>
</table>
<%= render 'shared/pagination', pagy: @pagy %>
<% end %>
<% end %>

View File

@@ -18,20 +18,61 @@
</div>
<div>
<!-- link to github -->
<%= link_to @build.commit_sha[0..6], "https://github.com/#{@project.repository_url}/commit/#{@build.commit_sha}", class: "underline", target: "_blank", rel: "noopener noreferrer" %>
<span class="font-light"><%= @build.commit_message.truncate(50) %></span>
<%= link_to(
@build.commit_sha[0..6],
"https://github.com/#{@project.repository_url}/commit/#{@build.commit_sha}",
class: "underline",
target: "_blank",
rel: "noopener noreferrer"
) %>
<span class="font-light"><%= @build.commit_message.truncate(75) %></span>
</div>
</div>
<hr class="mt-2 mb-6 border-base-content/10" />
<h2 class="text-xl font-bold mb-4">Build Logs</h2>
<h2 class="text-2xl font-bold">Build Logs</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<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 class="mt-6">
<h2 class="text-2xl font-bold">Release Logs</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<div class="my-4">
<%= render "log_outputs/logs", loggable: @build.deployment %>
</div>
</div>
<% end %>
<% if @build.deployment&.has_manifests? %>
<div class="mt-6" data-controller="content-toggle">
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold">Deployment Manifests</h2>
<button type="button"
class="btn btn-sm btn-outline"
data-action="click->content-toggle#toggleEdit"
data-content-toggle-target="editButton">
<iconify-icon icon="lucide:eye" height="16"></iconify-icon>
View
</button>
</div>
<hr class="mt-3 mb-4 border-t border-base-300" />
<div data-content-toggle-target="placeholder" class="p-4 bg-base-200 rounded-lg">
<p class="text-sm text-gray-500">Click view to see the Kubernetes manifests that were deployed.</p>
</div>
<div data-content-toggle-target="editorContainer" class="hidden">
<%= render "projects/deployments/manifest_browser", deployment: @build.deployment %>
<div class="mt-4">
<button type="button"
class="btn btn-outline btn-sm"
data-action="click->content-toggle#cancelEdit">
Close
</button>
</div>
</div>
</div>
<% end %>
<% end %>

View File

@@ -22,6 +22,13 @@
<%= render "projects/volumes/index", project: @project %>
</div>
<div>
<h2 class="text-2xl font-bold">Buildpacks</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<%= render "build_packs/search" %>
</div>
<div>
<h2 class="text-2xl font-bold">Danger zone</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />

View File

@@ -3,7 +3,7 @@
class="flex-1"
data-controller="environment-variables"
data-environment-variables-project-id-value="<%= @project.id %>"
data-environment-variables-vars-value="<%= @project.environment_variables.map { |e| { id: e.id, name: e.name, value: nil } }.to_json %>"
data-environment-variables-vars-value="<%= @environment_variables.map { |e| { id: e.id, name: e.name, value: nil, storage_type: e.storage_type } }.to_json %>"
>
<%= form_with(url: project_environment_variables_path(@project), method: :post) do |form| %>
<div data-environment-variables-target="container"></div>

View File

@@ -40,10 +40,5 @@
</div>
</div>
<% if @pagy.pages > 1 %>
<div class="flex items-center justify-end px-5 pb-5 pt-3">
<%== custom_pagy_nav(@pagy) %>
</div>
<% end %>
<%= render 'shared/pagination', pagy: @pagy %>
</div>

View File

@@ -1,3 +1,9 @@
<%= project_layout(@project) do %>
<%= render "log_outputs/pod_logs", logs: @logs, pod_events: @pod_events, back_path: project_processes_path(@project) %>
<%= render(
"log_outputs/pod_logs",
logs: @logs,
pod_events: @pod_events,
back_path: project_processes_path(@project),
error: @error
) %>
<% end %>

View File

@@ -0,0 +1,60 @@
<div>
<h2 class="text-xl font-bold">Resource Constraints</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<%= render "projects/services/resource_constraints/show", service: @service, resource_constraint: @service.resource_constraint %>
</div>
<div class="mt-8" data-controller="content-toggle">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold">Pod Template Configuration</h2>
<button type="button"
class="btn btn-sm btn-outline"
data-action="click->content-toggle#toggleEdit"
data-content-toggle-target="editButton">
<iconify-icon icon="lucide:pen" height="16"></iconify-icon>
Edit
</button>
</div>
<hr class="mt-3 mb-4 border-t border-base-300" />
<div data-content-toggle-target="placeholder" class="p-4 bg-base-200 rounded-lg">
<% if @service.pod_yaml.present? %>
<p class="text-sm text-gray-500">Custom pod template is configured. Click edit to view and modify.</p>
<% else %>
<p class="text-sm text-gray-500">No custom pod template configured. Click edit to add custom configuration.</p>
<% end %>
</div>
<div data-content-toggle-target="editorContainer" class="hidden">
<p class="my-2 text-sm text-gray-300">
Enter custom YAML configuration for the pod template spec. This will be merged with the default pod template in your <code>deployment.yaml</code> and <code>cron_job.yaml</code> files.
Only the pod template spec fields are supported (<code>containers</code>, <code>volumes</code>, <code>serviceAccountName</code>, etc.).
</p>
<%= form_with(model: @service, url: project_service_path(@service.project, @service), method: :put) do |form| %>
<div class="form-control" data-controller="yaml-editor">
<%= form.label :pod_yaml, "Pod Template YAML", class: "label" %>
<%= form.text_area :pod_yaml,
rows: 15,
placeholder: "# Example:\ncontainers:\n - name: sidecar\n image: nginx:latest\nvolumes:\n - name: cache\n emptyDir: {}",
class: "textarea textarea-bordered font-mono text-sm w-full",
data: { yaml_editor_target: "textarea" },
value: @service.pod_yaml.present? ? @service.pod_yaml.to_yaml : "" %>
<div data-yaml-editor-target="editor"></div>
<label class="label">
<span class="label-text-alt">Enter valid YAML for pod template spec fields</span>
</label>
</div>
<div class="form-footer mt-6 flex gap-2">
<%= form.submit @service.pod_yaml.present? ? "Update Pod Template" : "Save Pod Template", class: "btn btn-primary" %>
<button type="button"
class="btn btn-outline"
data-action="click->content-toggle#cancelEdit">
Cancel
</button>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,77 @@
<div class="mb-b">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold">
Run history for <code class="bg-base-300 px-2 py-1 rounded text-sm"><%= service.name %></code>
</h2>
<%= button_to "Run once", project_service_jobs_path(service.project, service), class: "btn btn-primary btn-sm #{!service.healthy? ? 'btn-disabled' : ''}", disabled: !service.healthy? %>
</div>
<hr class="mt-3 mb-4 border-t border-base-300" />
</div>
<% cron_job = K8::Stateless::CronJob.new(service).connect(active_connection) %>
<% run_history = cron_job.run_history.take(30) %>
<% if run_history.empty? %>
<div class="text-center py-12">
<p class="text-gray-500 text-md">This job has not been run yet.</p>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr class="border-b border-base-300">
<th class="text-left font-medium text-sm">Started</th>
<th class="text-left font-medium text-sm">Ran for</th>
<th class="text-left font-medium text-sm">Name</th>
<th class="text-left font-medium text-sm">Status</th>
<th class="text-left font-medium text-sm">Actions</th>
</tr>
</thead>
<tbody>
<% run_history.each do |job_run| %>
<%
duration_text = if job_run.duration
if job_run.duration < 60
"#{job_run.duration} seconds"
elsif job_run.duration < 3600
minutes = (job_run.duration / 60).round
"#{minutes} second#{minutes == 1 ? '' : 's'}"
else
hours = (job_run.duration / 3600.0).round(1)
"#{hours} hour#{hours == 1 ? '' : 's'}"
end
else
"..."
end
started_text = if job_run.started_at
time_diff = Time.now - job_run.started_at
if time_diff < 60
"#{time_diff.to_i} seconds ago"
elsif time_diff < 3600
minutes = (time_diff / 60).to_i
"#{minutes} minute#{minutes == 1 ? '' : 's'} ago"
else
hours = (time_diff / 3600).to_i
"#{hours} hour#{hours == 1 ? '' : 's'} ago"
end
else
"N/A"
end
%>
<tr class="hover:bg-base-300">
<td class="text-sm"><%= started_text %></td>
<td class="text-sm"><%= duration_text %></td>
<td class="text-sm font-medium"><%= job_run.name %></td>
<td>
<%= render "projects/services/jobs/status", status: job_run.status %>
</td>
<td>
<%= button_to "Delete", project_service_job_path(service.project, service, job_run.name), method: :delete, class: "btn btn-sm btn-error btn-outline" %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>

View File

@@ -1,36 +0,0 @@
<div>
<h3 class="text-sm font-medium mb-1">Recent Job Runs</h3>
<div class="flex flex-wrap gap-1">
<% cron_job = K8::Stateless::CronJob.new(service).connect(active_connection) %>
<% cron_job.run_history.take(20).each do |job_run| %>
<%
status_color = case job_run.status
when :succeeded then 'bg-success'
when :failed then 'bg-error'
when :running then 'bg-info'
else 'bg-base-300'
end
duration_text = if job_run.duration
if job_run.duration < 60
"#{job_run.duration}s"
elsif job_run.duration < 3600
"#{(job_run.duration / 60).round}m"
else
"#{(job_run.duration / 3600.0).round(1)}h"
end
else
"..."
end
%>
<div class="tooltip" data-tip="<%= job_run.name %>&#10;Status: <%= job_run.status %>&#10;Started: <%= job_run.started_at&.strftime('%Y-%m-%d %H:%M:%S') || 'N/A' %>&#10;Duration: <%= job_run.duration ? Time.at(job_run.duration).utc.strftime('%H:%M:%S') : 'In progress' %>">
<div class="<%= status_color %> text-xs font-medium rounded px-2 py-1 text-white min-w-[40px] text-center">
<%= duration_text %>
</div>
</div>
<% end %>
<% if cron_job.run_history.empty? %>
<p class="text-gray-500 text-md">This job has not been run yet.</p>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,21 @@
<div>
<div class="mb-6 space-y-2">
<h2 class="text-xl font-bold">Internal URL</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<pre
class="inline-block cursor-pointer"
data-controller="clipboard"
data-clipboard-text="<%= service.internal_url %>"><%= service.internal_url %></pre>
<%= render "projects/services/telepresence_guide", cluster: @project.cluster, url: "http://#{service.internal_url}" %>
</div>
<div>
<h2 class="text-xl font-bold">Public Networking</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<% if service.allow_public_networking? %>
<div class="form-control form-group">
<%= render "projects/services/domains/index", service: service %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,63 @@
<div>
<%= form_with(model: [service.project, service]) do |form| %>
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2">
<div>
<div class="form-control form-group">
<%= form.label :name %>
<%= form.text_field :name, class: "input input-bordered w-full", required: true, disabled: true %>
</div>
<div class="form-control form-group">
<%= form.label :command %>
<%= form.text_field :command, class: "input input-bordered w-full", required: false %>
</div>
<% if service.cron_job? %>
<div class="form-control form-group">
<%= form.fields_for :cron_schedule do |cron_schedule_form| %>
<%= cron_schedule_form.label :schedule %>
<%= cron_schedule_form.text_field :schedule, class: "input input-bordered w-full", placeholder: "0 0 * * *", value: service.cron_schedule.schedule %>
<% end %>
</div>
<% end %>
<% if service.web_service? %>
<div class="form-control form-group">
<%= form.label :container_port %>
<%= form.text_field :container_port, class: "input input-bordered w-full", required: false %>
</div>
<div class="form-control form-group">
<%= form.label :healthcheck_url %>
<%= form.text_field :healthcheck_url, class: "input input-bordered w-full", placeholder: "/health" %>
<span class="label-text-alt">Optional: The endpoint just needs to return a 200 status code to be considered healthy</span>
</div>
<div class="form-control rounded-lg bg-base-200 p-2 px-4">
<label class="label mt-1">
<span class="label-text cursor-pointer">Allow public networking</span>
<%= form.check_box :allow_public_networking, class: "checkbox" %>
</label>
</div>
<span class="label-text-alt">Checking this allows your service to be accessible from the public internet</span>
<% end %>
<% if service.web_service? || service.background_service? %>
<div>
<h2 class="text-lg my-2 mt-4">Resources</h2>
<div class="form-control form-group">
<%= form.label :replicas %>
<%= form.number_field :replicas, class: "input input-bordered w-full max-w-xs", placeholder: "1" %>
</div>
</div>
<% end %>
</div>
<div>
<%= form.label :description %>
<%= render "shared/partials/markdown_editor", form: form %>
</div>
</div>
<div class="form-footer">
<%= form.submit class: "btn btn-primary" %>
</div>
<% end %>
<%= button_to [service.project, service], method: :delete, class: "btn btn-error btn-outline mt-2", form: { data: { turbo_confirm: t("are_you_sure") } } do %>
<iconify-icon icon="lucide:trash" height="20" class="text-error-content"></iconify-icon>
Delete service
<% end %>
</div>

View File

@@ -1,95 +1,13 @@
<div>
<% if service.web_service? %>
<div class="mb-4 space-y-2">
<h6 class="text-lg font-bold">Internal URL</h6>
<pre
class="inline-block cursor-pointer"
data-controller="clipboard"
data-clipboard-text="<%= service.internal_url %>"><%= service.internal_url %></pre>
<%= render "projects/services/telepresence_guide", cluster: @project.cluster, url: "http://#{service.internal_url}" %>
</div>
<% end %>
<% if service.cron_job? %>
<div class="flex items-center justify-between mb-4">
<div class="flex-1">
<%= render "projects/services/cron_job_history", service: service %>
</div>
<div class="ml-4" data-tip="Run Job" class="tooltip">
<%= button_to "Run Job", project_service_jobs_path(service.project, service), class: "btn btn-primary btn-sm btn-outline #{!service.healthy? ? 'btn-disabled' : ''}", disabled: !service.healthy? %>
</div>
</div>
<% end %>
<%= form_with(model: [service.project, service]) do |form| %>
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2">
<div>
<div class="form-control form-group">
<%= form.label :name %>
<%= form.text_field :name, class: "input input-bordered w-full", required: true, disabled: true %>
</div>
<div class="form-control form-group">
<%= form.label :command %>
<%= form.text_field :command, class: "input input-bordered w-full", required: false %>
</div>
<% if service.cron_job? %>
<div class="form-control form-group">
<%= form.fields_for :cron_schedule do |cron_schedule_form| %>
<%= cron_schedule_form.label :schedule %>
<%= cron_schedule_form.text_field :schedule, class: "input input-bordered w-full", placeholder: "0 0 * * *", value: service.cron_schedule.schedule %>
<% end %>
</div>
<% end %>
<% if service.web_service? %>
<div class="form-control form-group">
<%= form.label :container_port %>
<%= form.text_field :container_port, class: "input input-bordered w-full", required: false %>
</div>
<div class="form-control form-group">
<%= form.label :healthcheck_url %>
<%= form.text_field :healthcheck_url, class: "input input-bordered w-full", placeholder: "/health" %>
<span class="label-text-alt">Optional: The endpoint just needs to return a 200 status code to be considered healthy</span>
</div>
<div class="form-control rounded-lg bg-base-200 p-2 px-4">
<label class="label mt-1">
<span class="label-text cursor-pointer">Allow public networking</span>
<%= form.check_box :allow_public_networking, class: "checkbox" %>
</label>
</div>
<span class="label-text-alt">Checking this allows your service to be accessible from the public internet</span>
<% end %>
<% if service.web_service? || service.background_service? %>
<div>
<h2 class="text-lg my-2 mt-4">Resources</h2>
<div class="form-control form-group">
<%= form.label :replicas %>
<%= form.number_field :replicas, class: "input input-bordered w-full max-w-xs", placeholder: "1" %>
</div>
</div>
<% end %>
</div>
<div>
<%= form.label :description %>
<%= render "shared/partials/markdown_editor", form: form %>
</div>
</div>
<div class="form-footer">
<%= form.submit class: "btn btn-primary" %>
</div>
<% end %>
<%= button_to [service.project, service], method: :delete, class: "btn btn-error btn-outline mt-2", form: { data: { turbo_confirm: t("are_you_sure") } } do %>
<iconify-icon icon="lucide:trash" height="20" class="text-error-content"></iconify-icon>
Delete service
<% end %>
<% if service.web_service? && service.allow_public_networking? %>
<div class="my-8">
<h2 class="text-2xl font-bold">Networking</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<div class="form-control form-group">
<%= render "projects/services/domains/index", service: service %>
</div>
</div>
<% end %>
</div>
<%= turbo_frame_tag("service_#{service.id}", data: { turbo_tabs_target: "content" }) do %>
<div class="my-6">
<% if tab == "overview" %>
<%= render "projects/services/overview", service:, tab: %>
<% elsif tab == "cron-jobs" %>
<%= render "projects/services/cron_job", service:, tab: %>
<% elsif tab == "networking" %>
<%= render "projects/services/networking", service:, tab: %>
<% elsif tab == "advanced" %>
<%= render "projects/services/advanced", service:, tab: %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,13 @@
<div role="tablist" class="tabs tabs-bordered" data-turbo-tabs-target="tabs">
<%= link_to "Overview", project_service_path(service.project, service, tab: 'overview'), class: "tab #{'tab-active' if tab == 'overview'}" %>
<% if service.web_service? %>
<%= link_to "Networking", project_service_path(service.project, service, tab: 'networking'), class: "tab #{'tab-active' if tab == 'networking'}" %>
<% end %>
<% if service.cron_job? %>
<%= link_to "Cron Job History", project_service_path(service.project, service, tab: 'cron-jobs'), class: "tab #{'tab-active' if tab == 'cron-jobs'}" %>
<% end %>
<%= link_to "Advanced", project_service_path(service.project, service, tab: 'advanced'), class: "tab #{'tab-active' if tab == 'advanced'}" %>
</div>

View File

@@ -21,16 +21,14 @@
<div class="collapse-title">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-xl font-medium">
<div>
<% if service.web_service? %>
<iconify-icon icon="ph:globe-duotone" height="16"></iconify-icon>
<% elsif service.background_service? %>
<iconify-icon icon="ph:server" height="16"></iconify-icon>
<% elsif service.cron_job? %>
<iconify-icon icon="ph:clock" height="16"></iconify-icon>
<% end %>
<%= service.name %>
</div>
<% if service.web_service? %>
<iconify-icon icon="lucide:globe" height="16"></iconify-icon>
<% elsif service.background_service? %>
<iconify-icon icon="lucide:cpu" height="16"></iconify-icon>
<% elsif service.cron_job? %>
<iconify-icon icon="lucide:clock" height="16"></iconify-icon>
<% end %>
<%= service.name %>
</div>
<div class="my-1">
<%= render "projects/services/status", service: service %>
@@ -38,7 +36,10 @@
</div>
</div>
<div class="collapse-content">
<%= render "projects/services/show", service: service %>
<div data-controller="turbo-tabs">
<%= render "projects/services/tabs", service:, tab: "overview" %>
<%= render "projects/services/show", service:, tab: "overview" %>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,14 @@
<%
badge_color = if status == :succeeded
"success"
elsif status == :failed
"error"
elsif status == :running
"info"
else
"warning"
end
%>
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
<%= status.to_s.humanize %>
</div>

View File

@@ -0,0 +1,49 @@
<div data-controller="resource-constraints--slider" data-resource-constraints--slider-type-value="float" data-resource-constraints--form-target="<%= key %>Field">
<% value = resource_constraint.send(key) %>
<div class="flex items-center justify-between mb-4">
<label class="flex items-center gap-2 cursor-pointer">
<%= check_box_tag "enable_#{key}", "1", value.present?,
class: "checkbox checkbox-sm",
data: {
action: "change->resource-constraints--form#toggleField",
resource_constraints__form_field_param: key
} %>
<span class="text-sm">Enable <%= key.to_s.gsub("cpu", "CPU").gsub("_", " ") %></span>
</label>
<div class="flex items-center">
<%= form.text_field(
key,
type: "number",
class: "input input-sm input-bordered font-mono text-sm w-[125px] text-right mr-2 #{'opacity-50 cursor-not-allowed' if value.blank?}",
value: value.present? ? (value / 1000.0) : '',
step: :any,
required: false,
readonly: value.blank?,
data: {
resource_constraints__slider_target: "numberInput",
resource_constraints__form_target: "input",
action: "input->resource-constraints--slider#updateSlider"
}
) %>
<span class="text-gray-200">cores</span>
</div>
</div>
<div class="mt-2">
<input
type="range"
min="0.1"
max="16"
step="0.1"
value="<%= value.present? ? (value / 1000.0) : 0.5 %>"
class="w-full h-2 bg-base-300 rounded-lg appearance-none cursor-pointer slider"
disabled="<%= value.blank? %>"
data-action="input->resource-constraints--slider#updateValue"
data-resource-constraints--slider-target="slider"
data-resource-constraints--form-target="slider"
>
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span>0.1</span>
<span>16.0</span>
</div>
</div>
</div>

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