mirror of
https://github.com/czhu12/canine.git
synced 2025-12-21 10:49:49 -06:00
Merge branch 'main' into celina__token_interface
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
30
Gemfile.lock
30
Gemfile.lock
@@ -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)
|
||||
|
||||
40
README.md
40
README.md
@@ -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
|
||||
[](https://artifacthub.io/packages/search?repo=canine)
|
||||
|
||||
|
||||

|
||||

|
||||
|
||||
## 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).
|
||||
|
||||
78
app/actions/buildpacks/details.rb
Normal file
78
app/actions/buildpacks/details.rb
Normal 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
|
||||
94
app/actions/buildpacks/search.rb
Normal file
94
app/actions/buildpacks/search.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
35
app/actions/projects/initialize_build_packs.rb
Normal file
35
app/actions/projects/initialize_build_packs.rb
Normal 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
|
||||
@@ -12,7 +12,8 @@ module Projects
|
||||
build_configuration:,
|
||||
params:
|
||||
).reduce(
|
||||
Projects::UpdateSave
|
||||
Projects::UpdateSave,
|
||||
Projects::UpdateBuildPacks
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
55
app/actions/projects/update_build_packs.rb
Normal file
55
app/actions/projects/update_build_packs.rb
Normal 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
|
||||
9
app/actions/resource_constraints/create.rb
Normal file
9
app/actions/resource_constraints/create.rb
Normal 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
|
||||
29
app/actions/resource_constraints/save.rb
Normal file
29
app/actions/resource_constraints/save.rb
Normal 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
|
||||
9
app/actions/resource_constraints/update.rb
Normal file
9
app/actions/resource_constraints/update.rb
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
16
app/avo/resources/build_pack.rb
Normal file
16
app/avo/resources/build_pack.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
4
app/controllers/avo/build_packs_controller.rb
Normal file
4
app/controllers/avo/build_packs_controller.rb
Normal 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
|
||||
22
app/controllers/build_packs_controller.rb
Normal file
22
app/controllers/build_packs_controller.rb
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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?
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
5
app/helpers/services_helper.rb
Normal file
5
app/helpers/services_helper.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
module ServicesHelper
|
||||
def services_layout(service, tab, &block)
|
||||
render layout: 'projects/services/layout', locals: { service:, tab: }, &block
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
257
app/javascript/controllers/buildpack_fields_controller.js
Normal file
257
app/javascript/controllers/buildpack_fields_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
46
app/javascript/controllers/buildpack_search_controller.js
Normal file
46
app/javascript/controllers/buildpack_search_controller.js
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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]");
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
86
app/javascript/controllers/manifest_browser_controller.js
Normal file
86
app/javascript/controllers/manifest_browser_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
app/javascript/controllers/turbo_tabs_controller.js
Normal file
21
app/javascript/controllers/turbo_tabs_controller.js
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
82
app/models/build_pack.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
50
app/models/resource_constraint.rb
Normal file
50
app/models/resource_constraint.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
86
app/services/builders/frontends/buildpack_builder.rb
Normal file
86
app/services/builders/frontends/buildpack_builder.rb
Normal 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
|
||||
45
app/services/builders/frontends/dockerfile_builder.rb
Normal file
45
app/services/builders/frontends/dockerfile_builder.rb
Normal 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
|
||||
@@ -64,6 +64,8 @@ class Git::Github::Client < Git::Client
|
||||
|
||||
def webhook_exists?
|
||||
webhook.present?
|
||||
rescue Octokit::NotFound
|
||||
false
|
||||
end
|
||||
|
||||
def remove_webhook!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
8
app/services/k8/stateless/secrets.rb
Normal file
8
app/services/k8/stateless/secrets.rb
Normal 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
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
7
app/views/build_packs/_search.html.erb
Normal file
7
app/views/build_packs/_search.html.erb
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<%= render 'shared/navbar' %>
|
||||
<%= render 'shared/notices' %>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div class="content-wrapper container">
|
||||
<%= yield %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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>
|
||||
|
||||
45
app/views/projects/deployments/_manifest_browser.html.erb
Normal file
45
app/views/projects/deployments/_manifest_browser.html.erb
Normal 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>
|
||||
@@ -42,5 +42,7 @@
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<%= render 'shared/pagination', pagy: @pagy %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
60
app/views/projects/services/_advanced.html.erb
Normal file
60
app/views/projects/services/_advanced.html.erb
Normal 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>
|
||||
77
app/views/projects/services/_cron_job.html.erb
Normal file
77
app/views/projects/services/_cron_job.html.erb
Normal 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 %>
|
||||
@@ -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 %> Status: <%= job_run.status %> Started: <%= job_run.started_at&.strftime('%Y-%m-%d %H:%M:%S') || 'N/A' %> 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>
|
||||
21
app/views/projects/services/_networking.html.erb
Normal file
21
app/views/projects/services/_networking.html.erb
Normal 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>
|
||||
63
app/views/projects/services/_overview.html.erb
Normal file
63
app/views/projects/services/_overview.html.erb
Normal 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>
|
||||
@@ -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 %>
|
||||
13
app/views/projects/services/_tabs.html.erb
Normal file
13
app/views/projects/services/_tabs.html.erb
Normal 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>
|
||||
@@ -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 %>
|
||||
|
||||
14
app/views/projects/services/jobs/_status.html.erb
Normal file
14
app/views/projects/services/jobs/_status.html.erb
Normal 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>
|
||||
@@ -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
Reference in New Issue
Block a user