Merge branch 'main' into chriszhu__comment_on_github

This commit is contained in:
Chris
2026-01-26 20:49:30 -08:00
119 changed files with 3364 additions and 232 deletions
+2 -1
View File
@@ -2,4 +2,5 @@ APP_HOST=canine.example.com
BOOT_MODE=cloud
OMNIAUTH_GITHUB_WEBHOOK_SECRET=1234567890
OMNIAUTH_GITHUB_PUBLIC_KEY=1234567890
OMNIAUTH_GITHUB_PRIVATE_KEY=1234567890
OMNIAUTH_GITHUB_PRIVATE_KEY=1234567890
ENABLE_AUTOMATIC_DNS_MAPPING=false
+1
View File
@@ -68,6 +68,7 @@ gem "ransack", "~> 4.2"
gem "cron2english", "~> 0.1.7"
gem "cssbundling-rails"
gem "devise", "~> 4.9"
gem "devise_invitable", "~> 2.0"
gem "dotenv", "~> 3.1"
gem "friendly_id", "~> 5.4"
gem "good_job", "~> 4.12"
+4
View File
@@ -168,6 +168,9 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise_invitable (2.0.11)
actionmailer (>= 5.0)
devise (>= 4.6)
diff-lcs (1.6.2)
docile (1.4.1)
domain_name (0.6.20240107)
@@ -741,6 +744,7 @@ DEPENDENCIES
database_cleaner-active_record (~> 2.2.2)
debug
devise (~> 4.9)
devise_invitable (~> 2.0)
doorkeeper (~> 5.8)
dotenv (~> 3.1)
factory_bot_rails
@@ -0,0 +1,23 @@
class Domains::AttachAutoManagedDomain
extend LightService::Action
expects :service
executed do |context|
next unless Dns::AutoSetupService.enabled?
service = context.service
next unless service.allow_public_networking?
next unless service.web_service?
next if service.domains.exists?(auto_managed: true)
domain_name = "#{service.name}-#{service.project.slug}.oncanine.run"
service.domains.create!(
domain_name: domain_name,
auto_managed: true
)
rescue StandardError => e
context.fail_and_return!(e.message)
end
end
+23
View File
@@ -0,0 +1,23 @@
class Domains::Destroy
extend LightService::Action
expects :domain
executed do |context|
domain = context.domain
if domain.auto_managed? && Dns::AutoSetupService.enabled?
cleanup_dns_record(domain)
end
domain.destroy!
end
def self.cleanup_dns_record(domain)
dns_client = Dns::Client.default
subdomain = domain.domain_name.chomp(".#{dns_client.domain}")
dns_client.delete_record(subdomain: subdomain)
rescue Dns::Client::Error => e
Rails.logger.error("[DNS Cleanup] Failed to delete record for #{domain.domain_name}: #{e.message}")
end
end
+1 -43
View File
@@ -2,50 +2,8 @@ class Networks::CheckDns
extend LightService::Action
expects :ingress, :connection
class << self
def infer_expected_dns(ingress, connection)
ingress.connect(connection)
dns_record = ingress.hostname
if dns_record[:type] == :ip_address && is_private_ip?(dns_record[:value])
cluster = ingress.service.project.cluster
# This only works if it is a single node cluster like k3s
public_ip = infer_public_ip_from_cluster(connection)
dns_record = {
value: public_ip,
type: :ip_address
}
end
dns_record
end
def is_private_ip?(ip)
ip.starts_with?("10.") || ip.starts_with?("172.") || ip.starts_with?("192.")
end
def infer_public_ip_from_cluster(connection)
# The ingress is reporting a private IP address, so we need to guess the public IP address
# based on the cluster's domain name
server_name = K8::Client.new(connection).server
# Parse the hostname from the server, with ruby's URI.parse
hostname = URI.parse(server_name).hostname
# If hostname is just an ip address, then we can return it
if ip?(hostname)
hostname
else
# Otherwise, we need to use Resolv to get the public IP address
Resolv.getaddress(hostname)
end
end
def ip?(ip)
ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
end
end
executed do |context|
# TODO
expected_dns = infer_expected_dns(context.ingress, context.connection)
expected_dns = Dns::Utils.infer_expected_hostname(context.ingress, context.connection)
context.ingress.service.domains.each do |domain|
if expected_dns[:type] == :ip_address
ip_addresses = Resolv::DNS.open do |dns|
+1
View File
@@ -5,6 +5,7 @@ class Services::Create
with(service:, params:).reduce(
Services::CreateAssociations,
Services::Save,
Domains::AttachAutoManagedDomain
)
end
end
+7
View File
@@ -5,11 +5,18 @@ class Services::Update
promises :service
executed do |context|
was_public = context.service.allow_public_networking?
context.service.update(Service.permitted_params(context.params))
if context.service.cron_job? && context.params[:service][:cron_schedule].present?
context.service.cron_schedule.update(
context.params[:service][:cron_schedule].permit(:schedule))
end
if !was_public && context.service.allow_public_networking?
Domains::AttachAutoManagedDomain.execute(service: context.service)
end
context.service.updated!
end
end
+54
View File
@@ -84,4 +84,58 @@
.slider::-moz-range-thumb {
@apply w-4 h-4 rounded-full bg-primary cursor-pointer border-0;
}
.alert-success {
background-color: oklch(var(--su) / 0.1) !important;
color: oklch(var(--su)) !important;
border: 0 !important;
}
.alert-error {
background-color: oklch(var(--er) / 0.1) !important;
color: oklch(var(--er)) !important;
border: 0 !important;
}
.alert-warning {
background-color: oklch(var(--wa) / 0.1) !important;
color: oklch(var(--wa)) !important;
border: 0 !important;
}
.alert-info {
background-color: oklch(var(--in) / 0.1) !important;
color: oklch(var(--in)) !important;
border: 0 !important;
}
.badge-success {
background-color: oklch(var(--su) / 0.1) !important;
color: oklch(var(--su)) !important;
border: 0 !important;
}
.badge-error {
background-color: oklch(var(--er) / 0.1) !important;
color: oklch(var(--er)) !important;
border: 0 !important;
}
.badge-warning {
background-color: oklch(var(--wa) / 0.1) !important;
color: oklch(var(--wa)) !important;
border: 0 !important;
}
.badge-info {
background-color: oklch(var(--in) / 0.1) !important;
color: oklch(var(--in)) !important;
border: 0 !important;
}
.badge-secondary {
background-color: oklch(var(--s) / 0.1) !important;
color: oklch(var(--s)) !important;
border: 0 !important;
}
+16
View File
@@ -0,0 +1,16 @@
class Avo::Resources::Notifier < 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 :project, as: :belongs_to
field :name, as: :text
field :provider_type, as: :number
field :webhook_url, as: :text
field :enabled, as: :boolean
end
end
+1 -2
View File
@@ -4,7 +4,7 @@ class Avo::Resources::User < Avo::BaseResource
query: -> { query.ransack(email_cont: params[:q], first_name_cont: params[:q], last_name_cont: params[:q], m: "or").result(distinct: false) },
item: -> {
{
title: record.name.presence || record.email
title: record.name.present? ? "#{record.name} (#{record.email})" : record.email
}
}
}
@@ -16,7 +16,6 @@ class Avo::Resources::User < Avo::BaseResource
field :email, as: :text, link_to_record: true
field :first_name, as: :text
field :last_name, as: :text
field :admin, as: :boolean
field :created_at, as: :date_time, sortable: true
field :avatar, as: :file, only_on: [ :show, :edit ]
@@ -1,14 +1,43 @@
class Accounts::AccountUsersController < ApplicationController
include SettingsHelper
def create
user = User.find_or_initialize_by(email: user_params[:email]) do |user|
user.first_name = user_params[:email].split("@").first
user.password = Devise.friendly_token[0, 20]
user.save!
end
AccountUser.create!(account: current_account, user: user)
email = user_params[:email].downcase
existing_member = current_account.users.find_by(email: email)
redirect_to account_users_path, notice: "User was successfully added."
if existing_member
redirect_to account_users_path, alert: "This user is already a member of this account."
return
end
user = User.find_by(email: email)
if user
AccountUser.create!(account: current_account, user: user)
redirect_to account_users_path, notice: "User was successfully added."
else
temp_password = generate_temp_password
user = User.new(
email: user_params[:email],
password: temp_password,
password_confirmation: temp_password,
first_name: user_params[:email].split("@").first,
password_change_required: true
)
user.skip_invitation = true
user.save!
AccountUser.create!(account: current_account, user: user)
@invite_credentials = {
email: user.email,
password: temp_password,
login_url: new_user_session_url
}
respond_to do |format|
format.turbo_stream
format.html { redirect_to account_users_path, notice: "User was successfully invited." }
end
end
end
def update
@@ -41,4 +70,8 @@ class Accounts::AccountUsersController < ApplicationController
def account_user_params
params.require(:account_user).permit(:role)
end
def generate_temp_password
"#{SecureRandom.alphanumeric(8)}!#{SecureRandom.alphanumeric(4)}"
end
end
@@ -1,10 +1,10 @@
module Accounts
class StackManagersController < ApplicationController
before_action :authenticate_user!
before_action :authorize_account, except: [ :verify_url, :check_reachable ]
before_action :authorize_account, except: [ :verify_url, :check_reachable, :verify_connectivity ]
before_action :set_stack_manager, only: [ :show, :edit, :update, :destroy, :sync_clusters, :sync_registries ]
before_action :set_stack, only: [ :sync_clusters, :sync_registries ]
skip_before_action :authenticate_user!, only: [ :verify_url, :check_reachable ]
skip_before_action :authenticate_user!, only: [ :verify_url, :check_reachable, :verify_connectivity ]
def check_reachable
url = params[:stack_manager][:url]
+10
View File
@@ -8,6 +8,7 @@ class ApplicationController < ActionController::Base
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :authenticate_user!
before_action :check_password_change_required
layout :determine_layout
@@ -71,4 +72,13 @@ class ApplicationController < ActionController::Base
flash[:alert] = "You are not authorized to perform this action."
redirect_back(fallback_location: root_path)
end
def check_password_change_required
return if devise_controller?
return unless user_signed_in?
return if true_user != current_user # Skip when impersonating
return unless current_user.password_change_required?
redirect_to password_change_path, alert: "Please change your password to continue."
end
end
@@ -6,6 +6,8 @@ class Avo::ImpersonationsController < Avo::ApplicationController
def create
user = User.find(params[:user_id])
impersonate_user(user)
# Set account to the impersonated user's first account
session[:account_id] = user.accounts.first&.id
redirect_to main_app.root_path, notice: "Now impersonating #{user.name}"
end
@@ -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::NotifiersController < Avo::ResourcesController
end
@@ -0,0 +1,29 @@
class Avo::PasswordResetsController < Avo::ApplicationController
def create
user = User.find(params[:user_id])
temp_password = generate_temp_password
user.update!(
password: temp_password,
password_confirmation: temp_password,
password_change_required: true
)
@credentials = {
email: user.email,
password: temp_password,
login_url: main_app.new_user_session_url
}
respond_to do |format|
format.turbo_stream
format.html { redirect_to avo.resources_user_path(user), notice: "Password reset. Temp password: #{temp_password}" }
end
end
private
def generate_temp_password
"#{SecureRandom.alphanumeric(8)}!#{SecureRandom.alphanumeric(4)}"
end
end
@@ -0,0 +1,15 @@
class Avo::PromoteToAdminsController < Avo::ApplicationController
def create
user = User.find(params[:user_id])
User.where(id: user.id).update_all(admin: true)
redirect_to avo.resources_user_path(user), notice: "#{user.name} has been promoted to site admin"
end
def destroy
user = User.find(params[:user_id])
User.where(id: user.id).update_all(admin: false)
redirect_to avo.resources_user_path(user), notice: "#{user.name} has been demoted from site admin"
end
end
@@ -0,0 +1,25 @@
class PasswordChangeController < ApplicationController
layout "homepage"
skip_before_action :check_password_change_required
def show
unless current_user.password_change_required?
redirect_to root_path
end
end
def update
if current_user.update_with_password(password_params.merge(password_change_required: false))
bypass_sign_in(current_user)
redirect_to root_path, notice: "Password changed successfully."
else
render :show, status: :unprocessable_entity
end
end
private
def password_params
params.require(:user).permit(:current_password, :password, :password_confirmation)
end
end
@@ -0,0 +1,46 @@
class Projects::NotifiersController < Projects::BaseController
before_action :set_notifier, only: [ :edit, :update, :destroy ]
def index
render partial: "index", locals: { project: @project }
end
def new
@notifier = @project.notifiers.new
end
def create
@notifier = @project.notifiers.build(notifier_params)
if @notifier.save
render partial: "index", locals: { project: @project }
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @notifier.update(notifier_params)
render partial: "index", locals: { project: @project }
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@notifier.destroy
render partial: "index", locals: { project: @project }
end
private
def set_notifier
@notifier = @project.notifiers.find(params[:id])
end
def notifier_params
params.require(:notifier).permit(:name, :provider_type, :webhook_url, :enabled)
end
end
@@ -21,7 +21,7 @@ class Projects::Services::DomainsController < Projects::Services::BaseController
def destroy
@domain = @project.domains.find(params[:id])
@domain.destroy
Domains::Destroy.execute(domain: @domain)
respond_to(&:turbo_stream)
end
@@ -1,4 +1,8 @@
class Projects::VolumesController < Projects::BaseController
def index
render partial: "index", locals: { project: @project }
end
def new
@volume = @project.volumes.new
end
@@ -7,19 +11,16 @@ class Projects::VolumesController < Projects::BaseController
@volume = @project.volumes.build(volume_params)
@project.updated!
if @volume.save
redirect_to edit_project_path(@project), notice: "Volume saved and will be created on the next deployment"
render partial: "index", locals: { project: @project }
else
redirect_to edit_project_path(@project), alert: "Failed to create volume"
render :new, status: :unprocessable_entity
end
end
def destroy
@volume = @project.volumes.find(params[:id])
if @volume.destroy
redirect_to edit_project_path(@project), notice: "Volume deleted and will be removed on the next deployment"
else
redirect_to edit_project_path(@project), alert: "Failed to delete volume"
end
@volume.destroy
render partial: "index", locals: { project: @project }
end
private
@@ -0,0 +1,29 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["trigger"];
connect() {
this.closeOnClickOutside = this.closeOnClickOutside.bind(this);
document.addEventListener("click", this.closeOnClickOutside);
}
disconnect() {
document.removeEventListener("click", this.closeOnClickOutside);
}
toggle(event) {
event.preventDefault();
this.element.classList.toggle("dropdown-open");
}
closeOnClickOutside(event) {
if (!this.element.contains(event.target)) {
this.element.classList.remove("dropdown-open");
}
}
close() {
this.element.classList.remove("dropdown-open");
}
}
+9
View File
@@ -9,6 +9,7 @@ class Projects::BuildJob < ApplicationJob
def perform(build, user)
project = build.project
build.in_progress!
notify_build(project, build)
# If its a container registry deploy, we don't need to build the docker image
if project.container_registry?
build.info("Skipping build for #{project.name} because it's a deploying from a container registry")
@@ -50,6 +51,7 @@ class Projects::BuildJob < ApplicationJob
unless build.killed?
build.error(e.message)
build.failed!
notify_build(project, build)
end
raise e
end
@@ -77,6 +79,7 @@ class Projects::BuildJob < ApplicationJob
def complete_build!(build, user)
build.completed!
notify_build(build.project, build)
deployment = Deployment.create!(build:)
Projects::DeploymentJob.perform_later(deployment, user)
end
@@ -91,4 +94,10 @@ class Projects::BuildJob < ApplicationJob
image_builder.build_image(repository_path)
end
end
def notify_build(project, build)
return unless project.notifiers.enabled.any?
BuildNotifier.with(project: project, build: build).deliver_later
end
end
+7
View File
@@ -4,6 +4,7 @@ class Projects::DestroyJob < ApplicationJob
uninstall_service_class(project).new(project, user).call
cleanup_auto_managed_domains(project)
remove_github_webhook(project) if should_remove_webhook?(project)
project.destroy!
end
@@ -31,4 +32,10 @@ class Projects::DestroyJob < ApplicationJob
rescue Octokit::NotFound
# If the hook is not found, do nothing
end
def cleanup_auto_managed_domains(project)
project.domains.where(auto_managed: true).find_each do |domain|
Domains::Destroy.cleanup_dns_record(domain)
end
end
end
@@ -0,0 +1,38 @@
class Scheduled::SweepDnsRecordsJob < ApplicationJob
queue_as :default
def perform
return unless Dns::AutoSetupService.enabled?
dns_client = Dns::Client.default
valid_domains = Set.new
Domain.where(auto_managed: true).find_each { |d| valid_domains << d.domain_name }
dns_client.list_all_records.each do |record|
next unless stale_record?(record, valid_domains, dns_client.domain)
Rails.logger.info("[DNS Sweep] Deleting stale record: #{record['name']}")
delete_record(dns_client, record)
end
end
private
def stale_record?(record, valid_domains, base_domain)
return false unless record["name"].end_with?(".#{base_domain}")
return false if valid_domains.include?(record["name"])
# Only sweep A and CNAME records that match the auto-managed pattern
return false unless %w[A CNAME].include?(record["type"])
subdomain = record["name"].chomp(".#{base_domain}")
subdomain.match?(/\A[a-z0-9-]+-[a-z0-9-]+\z/)
end
def delete_record(dns_client, record)
subdomain = record["name"].chomp(".#{dns_client.domain}")
dns_client.delete_record(subdomain: subdomain)
rescue Dns::Client::Error => e
Rails.logger.error("[DNS Sweep] Failed to delete #{record['name']}: #{e.message}")
end
end
+1
View File
@@ -3,6 +3,7 @@
# Table name: domains
#
# id :bigint not null, primary key
# auto_managed :boolean default(FALSE)
# domain_name :string not null
# status :integer default("checking_dns")
# status_reason :string
+58
View File
@@ -0,0 +1,58 @@
# == Schema Information
#
# Table name: notifiers
#
# id :bigint not null, primary key
# enabled :boolean default(TRUE), not null
# name :string not null
# provider_type :integer default("slack"), not null
# webhook_url :string not null
# created_at :datetime not null
# updated_at :datetime not null
# project_id :bigint not null
#
# Indexes
#
# index_notifiers_on_project_id (project_id)
#
# Foreign Keys
#
# fk_rails_... (project_id => projects.id)
#
class Notifier < ApplicationRecord
belongs_to :project
enum :provider_type, { slack: 0, discord: 1, microsoft_teams: 2, google_chat: 3 }
validates :name, presence: true
validates :webhook_url, presence: true,
format: { with: URI::DEFAULT_PARSER.make_regexp(%w[https]), message: "must be a valid HTTPS URL" }
validate :webhook_url_matches_provider
scope :enabled, -> { where(enabled: true) }
private
def webhook_url_matches_provider
return if webhook_url.blank?
case provider_type
when "slack"
unless webhook_url.include?("hooks.slack.com")
errors.add(:webhook_url, "must be a valid Slack webhook URL")
end
when "discord"
unless webhook_url.include?("discord.com/api/webhooks") || webhook_url.include?("discordapp.com/api/webhooks")
errors.add(:webhook_url, "must be a valid Discord webhook URL")
end
when "microsoft_teams"
unless webhook_url.include?(".webhook.office.com") || webhook_url.include?("outlook.office.com/webhook")
errors.add(:webhook_url, "must be a valid Microsoft Teams webhook URL")
end
when "google_chat"
unless webhook_url.include?("chat.googleapis.com")
errors.add(:webhook_url, "must be a valid Google Chat webhook URL")
end
end
end
end
+11
View File
@@ -18,6 +18,7 @@
# predestroy_command :text
# project_fork_status :integer default("disabled")
# repository_url :string not null
# slug :string not null
# status :integer default("creating"), not null
# created_at :datetime not null
# updated_at :datetime not null
@@ -28,6 +29,7 @@
#
# index_projects_on_cluster_id (cluster_id)
# index_projects_on_name (name)
# index_projects_on_slug (slug) UNIQUE
#
# Foreign Keys
#
@@ -55,6 +57,7 @@ class Project < ApplicationRecord
has_many :domains, through: :services
has_many :events, dependent: :destroy
has_many :volumes, dependent: :destroy
has_many :notifiers, dependent: :destroy
has_one :project_credential_provider, dependent: :destroy
has_one :build_configuration, dependent: :destroy
@@ -79,6 +82,7 @@ class Project < ApplicationRecord
validate :project_fork_cluster_id_is_owned_by_account
validates_presence_of :build_configuration, if: :git?
validates_presence_of :deployment_configuration
before_create :generate_slug
after_save_commit do
broadcast_replace_to [ self, :status ], target: dom_id(self, :status), partial: "projects/status", locals: { project: self }
@@ -100,6 +104,13 @@ class Project < ApplicationRecord
delegate :git?, :github?, :gitlab?, to: :project_credential_provider
delegate :container_registry?, to: :project_credential_provider
def generate_slug
self.slug = self.name
while Project.exists?(slug: self.slug)
self.slug = "#{self.name}-#{SecureRandom.uuid[0..7]}"
end
end
def project_fork_cluster_id_is_owned_by_account
if project_fork_cluster_id.present? && !account.clusters.exists?(id: project_fork_cluster_id)
errors.add(:project_fork_cluster_id, "must be owned by the account")
+11 -1
View File
@@ -20,7 +20,7 @@
#
# Indexes
#
# index_services_on_project_id (project_id)
# index_services_on_project_id_and_name (project_id,name) UNIQUE
#
# Foreign Keys
#
@@ -59,6 +59,16 @@ class Service < ApplicationRecord
K8::Stateless::Service.new(self).internal_url
end
def auto_subdomain
"#{name}-#{project.namespace}"
end
def auto_domain
return nil unless allow_public_networking?
"#{auto_subdomain}.#{Dns::Client.default.domain}"
end
def friendly_status
if !web_service? && healthy?
"deployed"
+13 -1
View File
@@ -8,22 +8,34 @@
# email :string default(""), not null
# encrypted_password :string default(""), not null
# first_name :string
# invitation_accepted_at :datetime
# invitation_created_at :datetime
# invitation_limit :integer
# invitation_sent_at :datetime
# invitation_token :string
# invitations_count :integer default(0)
# invited_by_type :string
# last_name :string
# password_change_required :boolean default(FALSE)
# remember_created_at :datetime
# reset_password_sent_at :datetime
# reset_password_token :string
# created_at :datetime not null
# updated_at :datetime not null
# invited_by_id :bigint
#
# Indexes
#
# index_users_on_email (email) UNIQUE
# index_users_on_invitation_token (invitation_token) UNIQUE
# index_users_on_invited_by (invited_by_type,invited_by_id)
# index_users_on_invited_by_id (invited_by_id)
# index_users_on_reset_password_token (reset_password_token) UNIQUE
#
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :recoverable
devise :database_authenticatable, :registerable, :rememberable, :validatable, :omniauthable
devise :invitable, :database_authenticatable, :registerable, :rememberable, :validatable, :omniauthable
has_one_attached :avatar
has_person_name
@@ -0,0 +1,2 @@
class ApplicationBulkDeliveryMethod < Noticed::BulkDeliveryMethod
end
@@ -0,0 +1,2 @@
class ApplicationDeliveryMethod < Noticed::DeliveryMethod
end
+7
View File
@@ -0,0 +1,7 @@
class ApplicationNotifier < Noticed::Event
bulk_deliver_by :webhook, class: "BulkDeliveryMethods::ProjectWebhook"
def project
params[:project]
end
end
+79
View File
@@ -0,0 +1,79 @@
class BuildNotifier < ApplicationNotifier
required_params :project, :build
def message
build = params[:build]
commit_info = build.commit_message.present? ? "\"#{build.commit_message.truncate(50)}\"" : build.commit_sha[0..7]
case build.status
when "in_progress"
"Building #{commit_info}"
when "completed"
"Build successful for #{commit_info}"
when "failed"
"Build failed for #{commit_info}"
when "killed"
"Build cancelled for #{commit_info}"
else
"Build #{build.status}"
end
end
def url
project_deployment_url(params[:project], params[:build])
end
def success?
params[:build].status == "completed"
end
def in_progress?
params[:build].status == "in_progress"
end
def build_payload(provider_type)
build = params[:build]
project = params[:project]
builder = WebhookBuilder.new
.title(project.name)
.description(message)
.url(url, label: "View Build")
.status(emoji: status_emoji, text: status_text, state: status_state)
builder.widget(label: "Status", value: "#{status_emoji} #{status_text}")
if build.commit_sha.present?
commit_url = "https://github.com/#{project.repository_url}/commit/#{build.commit_sha}"
builder.widget(label: "SHA", value: build.commit_sha[0..7], link: commit_url)
end
builder.widget(label: "Commit", value: build.commit_message.truncate(100)) if build.commit_message.present?
builder.build(provider_type)
end
private
def status_state
return :success if success?
return :in_progress if in_progress?
:failed
end
def status_text
case status_state
when :success then "Success"
when :in_progress then "Building"
else "Failed"
end
end
def status_emoji
case status_state
when :success then ""
when :in_progress then "🔨"
else ""
end
end
end
@@ -0,0 +1,23 @@
class BulkDeliveryMethods::ProjectWebhook < ApplicationBulkDeliveryMethod
def deliver
project = event.params[:project]
return unless project
project.notifiers.enabled.find_each do |notifier|
payload = event.build_payload(notifier.provider_type)
send_webhook(notifier.webhook_url, payload)
end
end
private
def send_webhook(url, payload)
HTTParty.post(
url,
headers: { "Content-Type" => "application/json" },
body: payload.to_json
)
rescue StandardError => e
Rails.logger.error "Failed to send webhook notification: #{e.message}"
end
end
+86
View File
@@ -0,0 +1,86 @@
class DeploymentNotifier < Noticed::Event
required_params :project, :deployment
bulk_deliver_by :project_webhook, class: "BulkDeliveryMethods::ProjectWebhook"
def project
params[:project]
end
def message
deployment = params[:deployment]
version = "v#{deployment.version}"
commit_info = deployment.build.commit_message.present? ? "\"#{deployment.build.commit_message.truncate(50)}\"" : nil
case deployment.status
when "in_progress"
commit_info ? "Deploying #{version}: #{commit_info}" : "Deploying #{version}"
when "completed"
commit_info ? "Deployed #{version}: #{commit_info}" : "Successfully deployed #{version}"
when "failed"
commit_info ? "Deploy failed for #{version}: #{commit_info}" : "Deploy failed for #{version}"
else
"Deployment #{deployment.status}"
end
end
def url
project_deployment_url(params[:project], params[:deployment].build)
end
def success?
params[:deployment].status == "completed"
end
def in_progress?
params[:deployment].status == "in_progress"
end
def build_payload(provider_type)
deployment = params[:deployment]
project = params[:project]
build = deployment.build
cluster = project.cluster
builder = WebhookBuilder.new
.title(project.name)
.description(message)
.url(url, label: "View Deployment")
.status(emoji: status_emoji, text: status_text, state: status_state)
builder.widget(label: "Status", value: "#{status_emoji} #{status_text}")
builder.widget(label: "Version", value: deployment.version)
builder.widget(label: "Cluster", value: cluster.name, link: cluster_url(cluster))
builder.widget(label: "Commit", value: build.commit_message.truncate(100)) if build.commit_message.present?
if build.commit_sha.present?
commit_url = "https://github.com/#{project.repository_url}/commit/#{build.commit_sha}"
builder.widget(label: "SHA", value: build.commit_sha[0..7], link: commit_url)
end
builder.build(provider_type)
end
private
def status_state
return :success if success?
return :in_progress if in_progress?
:failed
end
def status_text
case status_state
when :success then "Deployed"
when :in_progress then "Deploying"
else "Failed"
end
end
def status_emoji
case status_state
when :success then ""
when :in_progress then "🚀"
else ""
end
end
end
+199
View File
@@ -0,0 +1,199 @@
class WebhookBuilder
def initialize
@title = nil
@description = nil
@url = nil
@color = nil
@widgets = []
end
def title(value)
@title = value
self
end
def description(value)
@description = value
self
end
def url(value, label: "View Details")
@url = value
@url_label = label
self
end
def color(success: nil, in_progress: nil, failed: nil)
@color = { success: success, in_progress: in_progress, failed: failed }
self
end
def status(emoji:, text:, state:)
@status_emoji = emoji
@status_text = text
@status_state = state
self
end
def widget(label:, value:, link: nil)
@widgets << { label: label, value: value, link: link }
self
end
def build(provider)
case provider.to_sym
when :slack
slack_payload
when :discord
discord_payload
when :microsoft_teams
microsoft_teams_payload
when :google_chat
google_chat_payload
else
raise ArgumentError, "Unknown provider: #{provider}"
end
end
private
def slack_payload
fields = @widgets.map do |w|
value = w[:link] ? "<#{w[:link]}|#{w[:value]}>" : w[:value]
{ title: w[:label], value: value, short: w[:value].to_s.length < 30 }
end
attachment = {
color: slack_color,
text: @description,
fields: fields,
footer: "Canine",
ts: Time.current.to_i
}
attachment[:actions] = [ { type: "button", text: @url_label, url: @url } ] if @url
{
text: "#{@status_emoji} *#{@title}*",
attachments: [ attachment ]
}
end
def discord_payload
fields = @widgets.map do |w|
value = w[:link] ? "[#{w[:value]}](#{w[:link]})" : w[:value]
{ name: w[:label], value: value, inline: w[:value].to_s.length < 30 }
end
embed = {
title: "#{@status_emoji} #{@title}",
description: @description,
color: discord_color,
fields: fields,
timestamp: Time.current.iso8601
}
embed[:url] = @url if @url
{ embeds: [ embed ] }
end
def microsoft_teams_payload
facts = @widgets.map do |w|
value = w[:link] ? "[#{w[:value]}](#{w[:link]})" : w[:value]
{ name: w[:label], value: value }
end
payload = {
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
themeColor: teams_color,
summary: "#{@title} - #{@status_text}",
sections: [
{
activityTitle: "#{@status_emoji} #{@title}",
activitySubtitle: @description,
facts: facts,
markdown: true
}
]
}
if @url
payload[:potentialAction] = [
{
"@type": "OpenUri",
name: @url_label,
targets: [ { os: "default", uri: @url } ]
}
]
end
payload
end
def google_chat_payload
widgets = @widgets.map do |w|
widget = {
decoratedText: {
topLabel: w[:label],
text: w[:value]
}
}
widget[:decoratedText][:onClick] = { openLink: { url: w[:link] } } if w[:link]
widget
end
if @url
widgets << {
buttonList: {
buttons: [
{
text: @url_label,
onClick: { openLink: { url: @url } }
}
]
}
}
end
{
cardsV2: [
{
cardId: "notification-#{Time.current.to_i}",
card: {
header: {
title: @title,
subtitle: @description
},
sections: [ { widgets: widgets } ]
}
}
]
}
end
def slack_color
case @status_state
when :success then "good"
when :in_progress then "#3B82F6"
else "danger"
end
end
def discord_color
case @status_state
when :success then 0x22C55E
when :in_progress then 0x3B82F6
else 0xEF4444
end
end
def teams_color
case @status_state
when :success then "22C55E"
when :in_progress then "3B82F6"
else "EF4444"
end
end
end
@@ -74,5 +74,18 @@ class Deployments::BaseDeploymentService
def complete_deployment!
@deployment.completed!
@project.deployed!
notify_deployment
end
def notify_deployment
return unless @project.notifiers.enabled.any?
DeploymentNotifier.with(project: @project, deployment: @deployment).deliver_later
end
def setup_automatic_dns(service)
service.domains.where(auto_managed: true).find_each do |domain|
Dns::AutoSetupService.new(domain, connection: @connection, logger: @logger).call
end
end
end
@@ -11,6 +11,7 @@ class Deployments::HelmDeploymentService < Deployments::BaseDeploymentService
predeploy
deploy_services
@chart_builder.install_chart(@project.name)
setup_dns_for_services
kill_one_off_containers
postdeploy
@@ -69,4 +70,10 @@ class Deployments::HelmDeploymentService < Deployments::BaseDeploymentService
def mark_services_healthy
@project.services.each(&:healthy!)
end
def setup_dns_for_services
@project.services.each do |service|
setup_automatic_dns(service)
end
end
end
@@ -66,6 +66,7 @@ class Deployments::LegacyDeploymentService < Deployments::BaseDeploymentService
apply_resource("Ingress", service)
end
restart_deployment(service)
setup_automatic_dns(service)
end
service.healthy!
end
+77
View File
@@ -0,0 +1,77 @@
class Dns::AutoSetupService
attr_reader :domain, :connection, :logger
def initialize(domain, connection:, logger: Rails.logger)
@domain = domain
@connection = connection
@logger = logger
end
def self.enabled?
ENV["ENABLE_AUTOMATIC_DNS_MAPPING"] == "true" &&
Dns::Cloudflare::API_TOKEN.present? &&
Dns::Cloudflare::ZONE_ID.present?
end
def call
return unless self.class.enabled?
raise ArgumentError, "Domain must be auto_managed" unless domain.auto_managed?
validate_domain_matches_zone!
logger.info("Setting up automatic DNS for #{domain.domain_name}")
hostname = fetch_expected_hostname
return unless hostname
create_dns_record(hostname)
rescue Dns::Client::Error => e
logger.error("Failed to create DNS record for #{domain.domain_name}: #{e.message}")
rescue StandardError => e
logger.error("DNS setup failed for #{domain.domain_name}: #{e.message}")
end
private
def validate_domain_matches_zone!
dns_client = Dns::Client.default
return if domain.domain_name.end_with?(".#{dns_client.domain}")
raise ArgumentError, "Domain #{domain.domain_name} does not match configured zone #{dns_client.domain}"
end
def service
domain.service
end
def subdomain
dns_client = Dns::Client.default
domain.domain_name.chomp(".#{dns_client.domain}")
end
def fetch_expected_hostname
ingress = K8::Stateless::Ingress.new(service)
Dns::Utils.infer_expected_hostname(ingress, connection)
end
def create_dns_record(hostname)
dns_client = Dns::Client.default
if hostname[:type] == :ip_address
create_a_record(dns_client, hostname[:value])
else
create_cname_record(dns_client, hostname[:value])
end
end
def create_a_record(dns_client, ip_address)
dns_client.create_a_record(subdomain: subdomain, ip_address: ip_address)
logger.success("Created A record: #{domain.domain_name} -> #{ip_address}")
end
def create_cname_record(dns_client, target)
dns_client.create_cname_record(subdomain: subdomain, target: target)
logger.success("Created CNAME record: #{domain.domain_name} -> #{target}")
end
end
+42
View File
@@ -0,0 +1,42 @@
class Dns::Client
class Error < StandardError; end
def self.for_provider(provider)
case provider.to_sym
when :cloudflare
Dns::Cloudflare.new
else
raise Error, "Unsupported DNS provider: #{provider}"
end
end
def self.default
Dns::Cloudflare.new
end
# Interface methods - subclasses must implement these
def create_a_record(subdomain:, ip_address:, proxied: false, ttl: 300)
raise NotImplementedError
end
def create_cname_record(subdomain:, target:, proxied: false, ttl: 300)
raise NotImplementedError
end
def delete_record(subdomain:)
raise NotImplementedError
end
def record_exists?(subdomain:)
raise NotImplementedError
end
def find_record(subdomain:)
raise NotImplementedError
end
def domain
raise NotImplementedError
end
end
+149
View File
@@ -0,0 +1,149 @@
class Dns::Cloudflare < Dns::Client
BASE_URL = "https://api.cloudflare.com/client/v4"
API_TOKEN = ENV["CLOUDFLARE_API_TOKEN"]
ZONE_ID = ENV["CLOUDFLARE_ZONE_ID"]
DOMAIN = ENV["CLOUDFLARE_DOMAIN"] || "oncanine.run"
attr_reader :api_token, :zone_id, :domain
def initialize(api_token: nil, zone_id: nil, domain: nil)
@api_token = api_token || API_TOKEN
@zone_id = zone_id || ZONE_ID
@domain = domain || DOMAIN
end
def create_a_record(subdomain:, ip_address:, proxied: false, ttl: 1)
name = build_fqdn(subdomain)
existing = find_record_by_name(name: name, type: "A")
if existing
update_record(record_id: existing["id"], type: "A", content: ip_address, proxied: proxied, ttl: ttl)
else
create_record(name: name, type: "A", content: ip_address, proxied: proxied, ttl: ttl)
end
end
def create_cname_record(subdomain:, target:, proxied: false, ttl: 1)
name = build_fqdn(subdomain)
existing = find_record_by_name(name: name, type: "CNAME")
if existing
update_record(record_id: existing["id"], type: "CNAME", content: target, proxied: proxied, ttl: ttl)
else
create_record(name: name, type: "CNAME", content: target, proxied: proxied, ttl: ttl)
end
end
def delete_record(subdomain:)
name = build_fqdn(subdomain)
record = find_record_by_name(name: name)
return false unless record
response = connection.delete("zones/#{zone_id}/dns_records/#{record['id']}")
handle_response(response)
true
end
def record_exists?(subdomain:)
find_record(subdomain: subdomain).present?
end
def find_record(subdomain:)
name = build_fqdn(subdomain)
find_record_by_name(name: name)
end
def list_records(type: nil, name: nil)
params = {}
params[:type] = type if type
params[:name] = name if name
response = connection.get("zones/#{zone_id}/dns_records", params)
data = handle_response(response)
data["result"]
end
def list_all_records(type: nil)
params = { per_page: 100, page: 1 }
params[:type] = type if type
all_records = []
loop do
response = connection.get("zones/#{zone_id}/dns_records", params)
data = handle_response(response)
all_records.concat(data["result"])
result_info = data["result_info"]
break if params[:page] >= result_info["total_pages"]
params[:page] += 1
end
all_records
end
def verify_connection
response = connection.get("user/tokens/verify")
data = handle_response(response)
data["success"]
rescue Dns::Client::Error
false
end
private
def build_fqdn(subdomain)
"#{subdomain}.#{domain}"
end
def find_record_by_name(name:, type: nil)
records = list_records(name: name, type: type)
records&.first
end
def create_record(name:, type:, content:, proxied:, ttl:)
payload = {
type: type,
name: name,
content: content,
ttl: ttl,
proxied: proxied
}
response = connection.post("zones/#{zone_id}/dns_records", payload.to_json)
data = handle_response(response)
data["result"]
end
def update_record(record_id:, type:, content:, proxied:, ttl:)
payload = {
type: type,
content: content,
ttl: ttl,
proxied: proxied
}
response = connection.patch("zones/#{zone_id}/dns_records/#{record_id}", payload.to_json)
data = handle_response(response)
data["result"]
end
def connection
@connection ||= Faraday.new(url: BASE_URL) do |faraday|
faraday.headers["Authorization"] = "Bearer #{api_token}"
faraday.headers["Content-Type"] = "application/json"
faraday.adapter Faraday.default_adapter
end
end
def handle_response(response)
body = JSON.parse(response.body)
unless body["success"]
errors = body["errors"]&.map { |e| e["message"] }&.join(", ") || "Unknown error"
raise Dns::Client::Error, errors
end
body
end
end
+37
View File
@@ -0,0 +1,37 @@
class Dns::Utils
class << self
def private_ip?(ip)
ip.start_with?("10.") || ip.start_with?("172.") || ip.start_with?("192.168.")
end
def ip_address?(str)
str.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
end
def infer_public_ip(connection)
server_name = K8::Client.new(connection).server
hostname = URI.parse(server_name).hostname
if ip_address?(hostname)
hostname
else
Resolv.getaddress(hostname)
end
end
def infer_expected_hostname(ingress, connection)
ingress.connect(connection)
hostname = ingress.hostname
# Only try to infer a public IP address if the cluster is a single node cluster (k3s, local_k3s)
if !connection.cluster.k8s? && hostname[:type] == :ip_address && private_ip?(hostname[:value])
public_ip = infer_public_ip(connection)
{ type: :ip_address, value: public_ip }
elsif hostname[:type] == :ip_address && private_ip?(hostname[:value])
raise "Private IP address detected for cluster type: #{connection.cluster.cluster_type}"
else
hostname
end
end
end
end
@@ -12,7 +12,7 @@ class Async::K8::ClusterIpViewModel < Async::BaseViewModel
def async_render
connection = K8::Connection.new(service.project, current_user)
ingress = K8::Stateless::Ingress.new(service)
record = Networks::CheckDns.infer_expected_dns(ingress, connection)
record = Dns::Utils.infer_expected_hostname(ingress, connection)
if record[:type] == :ip_address
ip = record[:value]
<<~HTML
@@ -0,0 +1,16 @@
class Async::K8::InfoViewModel < Async::BaseViewModel
expects :cluster_id
def server
cluster = current_user.clusters.find(params[:cluster_id])
K8::Client.new(K8::Connection.new(cluster, current_user)).server
end
def initial_render
"<div class='loading loading-spinner loading-sm'></div>"
end
def async_render
"<div class='text-sm text-gray-500'>#{server}</div>"
end
end
@@ -43,7 +43,7 @@
</td>
<td>
<% if current_account_user.admin_or_owner? && !account_user.owner? %>
<%= button_to account_user_path(account_user), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-ghost btn-sm btn-square" do %>
<%= button_to account_user_path(account_user), method: :delete, data: { confirm: "Remove #{account_user.user.name.presence || account_user.user.email} from #{current_account.name}? They will lose access to all projects and resources in this account." }, class: "btn btn-ghost btn-sm btn-square" do %>
<iconify-icon icon="lucide:trash" height="18" class="text-error"></iconify-icon>
<% end %>
<% end %>
@@ -0,0 +1,67 @@
<%= turbo_stream.replace "team_member_modal" do %>
<dialog aria-label="Modal" class="modal modal-open" id="team_member_modal">
<div class="modal-box">
<form method="dialog">
<button aria-label="Close modal" class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" onclick="window.location.reload()">
<iconify-icon icon="lucide:x" height="16"></iconify-icon>
</button>
</form>
<div class="mb-4 w-full text-xl font-bold flex items-center gap-2">
<iconify-icon icon="lucide:check-circle" class="text-success" height="24"></iconify-icon>
Invitation Created
</div>
<p class="text-sm text-base-content/70 mb-4">
Share these credentials with the new team member. They will be prompted to change their password on first login.
</p>
<div class="space-y-3">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Login URL</span>
</label>
<div class="input input-bordered flex items-center gap-2 bg-base-200">
<span class="truncate flex-1 text-sm"><%= @invite_credentials[:login_url] %></span>
<button type="button" class="btn btn-ghost btn-xs" onclick="navigator.clipboard.writeText('<%= @invite_credentials[:login_url] %>')">
<iconify-icon icon="lucide:copy" height="14"></iconify-icon>
</button>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Email</span>
</label>
<div class="input input-bordered flex items-center gap-2 bg-base-200">
<span class="truncate flex-1 text-sm"><%= @invite_credentials[:email] %></span>
<button type="button" class="btn btn-ghost btn-xs" onclick="navigator.clipboard.writeText('<%= @invite_credentials[:email] %>')">
<iconify-icon icon="lucide:copy" height="14"></iconify-icon>
</button>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Temporary Password</span>
</label>
<div class="input input-bordered flex items-center gap-2 bg-base-200">
<code class="truncate flex-1 text-sm"><%= @invite_credentials[:password] %></code>
<button type="button" class="btn btn-ghost btn-xs" onclick="navigator.clipboard.writeText('<%= @invite_credentials[:password] %>')">
<iconify-icon icon="lucide:copy" height="14"></iconify-icon>
</button>
</div>
</div>
</div>
<button type="button" class="btn btn-outline btn-block mt-4" onclick="navigator.clipboard.writeText(`Login URL: <%= @invite_credentials[:login_url] %>\nEmail: <%= @invite_credentials[:email] %>\nTemporary Password: <%= @invite_credentials[:password] %>`); this.innerHTML = '<iconify-icon icon=&quot;lucide:check&quot; height=&quot;16&quot;></iconify-icon> Copied!'; setTimeout(() => this.innerHTML = '<iconify-icon icon=&quot;lucide:copy&quot; height=&quot;16&quot;></iconify-icon> Copy All', 2000)">
<iconify-icon icon="lucide:copy" height="16"></iconify-icon>
Copy All
</button>
<div class="alert alert-warning mt-4">
<iconify-icon icon="lucide:alert-triangle" height="16"></iconify-icon>
<span class="text-sm">This password will not be shown again. Make sure to copy it now.</span>
</div>
<div class="modal-action">
<button class="btn btn-primary" onclick="window.location.reload()">Done</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick="window.location.reload()">close</button>
</form>
</dialog>
<% end %>
@@ -68,7 +68,7 @@
</label>
<%= form.text_field :email, type: :email, class: "input input-bordered w-full focus:outline-offset-0" %>
<label class="label">
<span class="label-text-alt">Use the email associated with their Canine account.</span>
<span class="label-text-alt">If the user doesn't have an account, you'll get a temporary password to send them.</span>
</label>
</div>
<div class="form-footer">
+1 -1
View File
@@ -9,7 +9,7 @@
"warning"
end
%>
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
<%= add_on.status.humanize %>
<% if add_on.updating? || add_on.installing? %>
<iconify-icon class="ml-1 animate-spin" icon="lucide:loader-circle"></iconify-icon>
@@ -0,0 +1,77 @@
<%= turbo_stream.replace "user_tool_panel" do %>
<div id="user_tool_panel">
<%= render Avo::PanelComponent.new(name: "Password Reset Complete") do |c| %>
<% c.with_body do %>
<div class="p-4">
<!-- Success Header -->
<div class="flex items-center gap-2 mb-3">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-100">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</span>
<p class="text-sm text-gray-600">Share these credentials with the user.</p>
</div>
<!-- Credentials -->
<div class="space-y-3 mb-4">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Login URL</label>
<div class="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
<span class="flex-1 text-sm text-gray-900 truncate font-mono"><%= @credentials[:login_url] %></span>
<button type="button" onclick="navigator.clipboard.writeText('<%= @credentials[:login_url] %>'); this.innerHTML = '<svg class=\'w-4 h-4 text-green-600\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M5 13l4 4L19 7\'></path></svg>'; setTimeout(() => this.innerHTML = '<svg class=\'w-4 h-4\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\'></path></svg>', 1500)" class="text-gray-400 hover:text-gray-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Email</label>
<div class="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
<span class="flex-1 text-sm text-gray-900 truncate"><%= @credentials[:email] %></span>
<button type="button" onclick="navigator.clipboard.writeText('<%= @credentials[:email] %>'); this.innerHTML = '<svg class=\'w-4 h-4 text-green-600\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M5 13l4 4L19 7\'></path></svg>'; setTimeout(() => this.innerHTML = '<svg class=\'w-4 h-4\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\'></path></svg>', 1500)" class="text-gray-400 hover:text-gray-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Temporary Password</label>
<div class="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
<code class="flex-1 text-sm text-gray-900 font-mono"><%= @credentials[:password] %></code>
<button type="button" onclick="navigator.clipboard.writeText('<%= @credentials[:password] %>'); this.innerHTML = '<svg class=\'w-4 h-4 text-green-600\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M5 13l4 4L19 7\'></path></svg>'; setTimeout(() => this.innerHTML = '<svg class=\'w-4 h-4\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\'></path></svg>', 1500)" class="text-gray-400 hover:text-gray-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
</div>
</div>
<!-- Copy All Button -->
<button type="button"
id="copy-all-btn"
onclick="navigator.clipboard.writeText(`Login URL: <%= @credentials[:login_url] %>\nEmail: <%= @credentials[:email] %>\nTemporary Password: <%= @credentials[:password] %>`); this.querySelector('span').innerText = 'Copied!'; setTimeout(() => this.querySelector('span').innerText = 'Copy All', 1500)"
class="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
<span>Copy All</span>
</button>
<!-- Warning -->
<div class="mt-3 flex items-start gap-2 p-2 bg-amber-50 border border-amber-200 rounded-md">
<svg class="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<span class="text-xs text-amber-800">This password won't be shown again. Copy it now.</span>
</div>
</div>
<% end %>
<% end %>
</div>
<% end %>
+58 -25
View File
@@ -1,31 +1,64 @@
<div class="flex flex-col">
<%= render Avo::PanelComponent.new(name: "User") do |c| %>
<% c.with_tools do %>
<%= a_link('/avo', icon: 'heroicons/solid/academic-cap', color: :primary, style: :primary) do %>
Login As
<% end %>
<% end %>
<div id="user_tool_panel">
<%= render Avo::PanelComponent.new(name: "Manage User") do |c| %>
<% c.with_body do %>
<div class="flex flex-col p-4 min-h-24">
<div class="space-y-4">
<h3>Manage User</h3>
<%= form_with url: avo.impersonation_path(user_id: @resource.record.id), method: :post, data: { turbo: false } do |f| %>
<%= f.submit "Login As", class: Avo::Config.button_css %>
<% end %>
<%
# In this partial you have access to the following variables:
# tool
# @resource
# @resource.record
# params
# Avo::Current.context
# current_user
%>
<div style="border-top: 1px solid #f3f4f6;">
<!-- Login As -->
<div style="padding: 1rem; border-bottom: 1px solid #f3f4f6;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<h4 style="font-size: 0.875rem; font-weight: 500; color: #111827;">Login as this user</h4>
<p style="font-size: 0.75rem; color: #6b7280; margin-top: 0.125rem;">Impersonate to see what they see</p>
</div>
<%= form_with url: avo.impersonation_path(user_id: @resource.record.id), method: :post, data: { turbo: false } do |f| %>
<button type="submit" style="display: inline-flex; align-items: center; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 0.375rem; color: white; background-color: #2563eb; border: none; cursor: pointer;">
Login As
</button>
<% end %>
</div>
</div>
<!-- Reset Password -->
<div style="padding: 1rem; border-bottom: 1px solid #f3f4f6;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<h4 style="font-size: 0.875rem; font-weight: 500; color: #111827;">Reset password</h4>
<p style="font-size: 0.75rem; color: #6b7280; margin-top: 0.125rem;">Generate a new temporary password</p>
</div>
<%= form_with url: avo.password_reset_path(user_id: @resource.record.id), method: :post do |f| %>
<button type="submit" style="display: inline-flex; align-items: center; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 0.375rem; color: #374151; background-color: #f3f4f6; border: none; cursor: pointer;" data-confirm="Generate a new temporary password for <%= @resource.record.email %>?">
Reset
</button>
<% end %>
</div>
</div>
<!-- Promote/Demote Site Admin -->
<div style="padding: 1rem;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<h4 style="font-size: 0.875rem; font-weight: 500; color: #111827;">Site Admin</h4>
<% if @resource.record.admin? %>
<p style="font-size: 0.75rem; color: #6b7280; margin-top: 0.125rem;">Remove site admin privileges</p>
<% else %>
<p style="font-size: 0.75rem; color: #6b7280; margin-top: 0.125rem;">Promote this user to site admin</p>
<% end %>
</div>
<% if @resource.record.admin? %>
<%= form_with url: avo.promote_to_admin_path(user_id: @resource.record.id), method: :delete do |f| %>
<button type="submit" style="display: inline-flex; align-items: center; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 0.375rem; color: #374151; background-color: #f3f4f6; border: none; cursor: pointer;" data-confirm="Demote <%= @resource.record.email %> from site admin?">
Demote
</button>
<% end %>
<% else %>
<%= form_with url: avo.promote_to_admin_path(user_id: @resource.record.id), method: :post do |f| %>
<button type="submit" style="display: inline-flex; align-items: center; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 0.375rem; color: white; background-color: #dc2626; border: none; cursor: pointer;" data-confirm="Promote <%= @resource.record.email %> to site admin?">
Promote
</button>
<% end %>
<% end %>
</div>
</div>
</div>
<% end %>
<% end %>
</div>
+7 -3
View File
@@ -1,3 +1,7 @@
<div class="text-sm text-gray-500">
<%= K8::Client.new(K8::Connection.new(cluster, current_user)).server %>
</div>
<%= render(
"shared/partials/async_renderer",
view_model: Async::K8::InfoViewModel.new(
current_user,
cluster_id: cluster.id
)
) %>
+1 -1
View File
@@ -15,7 +15,7 @@
end
%>
<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 %>">
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
<%= cluster.status %>
</div>
<% if cluster.status == "failed" %>
@@ -17,7 +17,7 @@
end
%>
<div class="text-right">
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
<%= build_cloud.status.humanize %>
<% if build_cloud.updating? || build_cloud.installing? %>
<iconify-icon class="ml-1 animate-spin" icon="lucide:loader-circle"></iconify-icon>
+47
View File
@@ -0,0 +1,47 @@
<div class="flex justify-center items-center min-h-screen bg-base-200">
<div class="card w-96 bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="text-2xl font-bold text-center">Change Your Password</h1>
<p class="text-center text-sm text-base-content/70">
You must change your temporary password before continuing.
</p>
<%= form_with(model: current_user, url: password_change_path, method: :patch, html: { class: "space-y-4 mt-4" }) do |f| %>
<% if current_user.errors.any? %>
<div class="alert alert-error">
<iconify-icon icon="lucide:alert-triangle" class="mr-2"></iconify-icon>
<span><%= current_user.errors.full_messages.to_sentence %></span>
</div>
<% end %>
<div class="form-control">
<%= f.label :current_password, class: "label" do %>
<span class="label-text">Current Password</span>
<% end %>
<%= f.password_field :current_password, autocomplete: "current-password", class: "input input-bordered w-full", required: true %>
<label class="label">
<span class="label-text-alt">Enter the temporary password you were given</span>
</label>
</div>
<div class="form-control">
<%= f.label :password, "New Password", class: "label" do %>
<span class="label-text">New Password</span>
<% end %>
<%= f.password_field :password, autocomplete: "new-password", class: "input input-bordered w-full", required: true %>
</div>
<div class="form-control">
<%= f.label :password_confirmation, class: "label" do %>
<span class="label-text">Confirm New Password</span>
<% end %>
<%= f.password_field :password_confirmation, autocomplete: "new-password", class: "input input-bordered w-full", required: true %>
</div>
<div class="form-control mt-6">
<%= f.submit "Change Password", class: "btn btn-primary w-full" %>
</div>
<% end %>
</div>
</div>
</div>
+3 -3
View File
@@ -1,11 +1,11 @@
<%= turbo_stream_from project, :status %>
<div id="<%= dom_id(project, :status) %>">
<% if project.last_build&.in_progress? %>
<div aria-label="Badge" class="badge border-0 bg-warning/10 font-medium capitalize text-warning">
<div aria-label="Badge" class="badge badge-warning font-medium capitalize">
Building <iconify-icon class="ml-1 animate-spin" icon="lucide:loader-circle"></iconify-icon>
</div>
<% elsif project.last_deployment&.in_progress? %>
<div aria-label="Badge" class="badge border-0 bg-warning/10 font-medium capitalize text-warning">
<div aria-label="Badge" class="badge badge-warning font-medium capitalize">
Deploying <iconify-icon class="ml-1 animate-spin" icon="lucide:loader-circle"></iconify-icon>
</div>
<% else %>
@@ -19,7 +19,7 @@
"error"
end
%>
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
<%= project.status %>
</div>
<% end %>
@@ -13,7 +13,7 @@
"error"
end
%>
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
<%= status %>
<% if status == "pending" %>
<iconify-icon class="ml-1 animate-spin" icon="lucide:loader-circle"></iconify-icon>
+7
View File
@@ -32,6 +32,13 @@
<%= render "build_packs/search" %>
</div>
<div>
<h2 class="text-2xl font-bold">Notifications</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<%= render "projects/notifiers/index", project: @project %>
</div>
<div>
<h2 class="text-2xl font-bold">Danger zone</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
@@ -0,0 +1,3 @@
<div class="text-sm text-base-content/60">
Get your webhook URL from Discord: <strong>Server Settings → Integrations → Webhooks → New Webhook</strong>
</div>
@@ -0,0 +1,87 @@
<%= form_with model: notifier, url: notifier.persisted? ? project_notifier_path(@project, notifier) : project_notifiers_path(@project) do |f| %>
<% if notifier.errors.any? %>
<div class="alert alert-error mb-4">
<ul>
<% notifier.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-control w-full max-w-sm">
<label class="label">
<span class="label-text">Name</span>
</label>
<%= f.text_field :name, class: "input input-bordered w-full", placeholder: "e.g. Deploy Alerts", required: true %>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">Provider</span>
</label>
<%= render "shared/partials/radio_selector",
selected: notifier.provider_type || "slack",
options: [
{
name: "notifier[provider_type]",
value: "slack",
href: nil,
icon: "logos:slack-icon",
label: "Slack",
description: "Send notifications to a Slack channel",
partial: "projects/notifiers/slack_instructions",
locals: {}
},
{
name: "notifier[provider_type]",
value: "discord",
href: nil,
icon: "logos:discord-icon",
label: "Discord",
description: "Send notifications to a Discord channel",
partial: "projects/notifiers/discord_instructions",
locals: {}
},
{
name: "notifier[provider_type]",
value: "microsoft_teams",
href: nil,
icon: "logos:microsoft-teams",
label: "Microsoft Teams",
description: "Send notifications to a Teams channel",
partial: "projects/notifiers/microsoft_teams_instructions",
locals: {}
},
{
name: "notifier[provider_type]",
value: "google_chat",
href: nil,
icon: "/images/logos/google-chat.webp",
label: "Google Chat",
description: "Send notifications to a Google Chat space",
partial: "projects/notifiers/google_chat_instructions",
locals: {}
}
] %>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">Webhook URL</span>
</label>
<%= f.text_field :webhook_url, class: "input input-bordered w-full font-mono text-sm", placeholder: "https://hooks.slack.com/services/..." %>
</div>
<div class="form-control rounded-lg bg-base-200 p-2 px-4 max-w-sm mt-4">
<label class="label mt-1">
<span class="label-text cursor-pointer">Enabled</span>
<%= f.check_box :enabled, class: "checkbox" %>
</label>
</div>
<div class="form-footer">
<%= f.submit notifier.persisted? ? "Update Notifier" : "Create Notifier", class: "btn btn-primary" %>
<%= link_to "Cancel", project_notifiers_path(@project), class: "btn btn-ghost", data: { turbo_frame: "notifiers" } %>
</div>
<% end %>
@@ -0,0 +1,3 @@
<div class="text-sm text-base-content/60">
Get your webhook URL from Google Chat: <strong>Space → Apps & integrations → Webhooks → Add webhook</strong>
</div>
@@ -0,0 +1,29 @@
<%= turbo_frame_tag "notifiers" do %>
<div class="space-y-4">
<div class="text-base-content/50">
Set up webhook notifications to receive real-time alerts about builds and deployments. Supports Slack, Discord, Microsoft Teams, and Google Chat.
</div>
<div>
<%= link_to "Add Notifier", new_project_notifier_path(project), class: "btn btn-primary btn-outline", data: { turbo_frame: "notifiers" } %>
</div>
<% if project.notifiers.any? %>
<div>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Provider</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<%= render partial: "projects/notifiers/notifier", collection: project.notifiers.order(:created_at), as: :notifier %>
</tbody>
</table>
</div>
<% end %>
</div>
<% end %>
@@ -0,0 +1,3 @@
<div class="text-sm text-base-content/60">
Get your webhook URL from Teams: <strong>Channel → Connectors → Incoming Webhook → Configure</strong>
</div>
@@ -0,0 +1,25 @@
<tr id="<%= dom_id(notifier) %>">
<td><%= notifier.name %></td>
<td>
<span class="badge badge-outline">
<%= notifier.provider_type.titleize %>
</span>
</td>
<td>
<% if notifier.enabled? %>
<span class="badge badge-success">Enabled</span>
<% else %>
<span class="badge badge-ghost">Disabled</span>
<% end %>
</td>
<td class="flex gap-2 justify-end">
<%= link_to "Edit", edit_project_notifier_path(notifier.project, notifier), class: "btn btn-sm btn-ghost", data: { turbo_frame: "notifiers" } %>
<%= button_to(
"Delete",
project_notifier_path(notifier.project, notifier),
method: :delete,
data: { turbo_confirm: "Are you sure you want to delete this #{notifier.provider_type} notifier?" },
class: "btn btn-sm btn-ghost text-error"
) %>
</td>
</tr>
@@ -0,0 +1,3 @@
<div class="text-sm text-base-content/60">
Get your webhook URL from Slack: <strong>Apps → Incoming Webhooks → Add New Webhook</strong>
</div>
@@ -0,0 +1,4 @@
<%= turbo_frame_tag "notifiers" do %>
<h3 class="text-lg font-bold mb-4">Edit Notifier</h3>
<%= render "form", notifier: @notifier %>
<% end %>
@@ -0,0 +1,4 @@
<%= turbo_frame_tag "notifiers" do %>
<h3 class="text-lg font-bold mb-4">New Notifier</h3>
<%= render "form", notifier: @notifier %>
<% end %>
+1 -1
View File
@@ -11,7 +11,7 @@
end
%>
<div class="text-right">
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
<%= service.friendly_status %>
</div>
<% if service.web_service? %>
@@ -10,7 +10,14 @@
<tbody>
<% service.domains.each do |domain| %>
<tr id="<%= dom_id(domain) %>">
<td><%= link_to domain.domain_name, "https://#{domain.domain_name}", target: "_blank" %></td>
<td>
<%= link_to domain.domain_name, "https://#{domain.domain_name}", target: "_blank" %>
<% if domain.auto_managed? %>
<div class="tooltip tooltip-right inline-block ml-2" data-tip="Automatically managed by Canine">
<span class="badge badge-sm badge-info">Auto</span>
</div>
<% end %>
</td>
<td>
<%= render(
"shared/partials/async_renderer",
@@ -8,10 +8,7 @@
"error"
end
%>
<div
aria-label="Badge"
class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>"
>
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
<%= domain.status.to_s.titleize %>
</div>
<% if domain.status_reason.present? && domain.dns_incorrect? %>
@@ -9,6 +9,6 @@
"warning"
end
%>
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
<%= status.to_s.humanize %>
</div>
+3 -2
View File
@@ -1,4 +1,4 @@
<%= form_with model: volume, url: project_volumes_path(volume.project), method: :post, data: { turbo_frame: "new_volume" } do |f| %>
<%= form_with model: volume, url: project_volumes_path(volume.project), method: :post do |f| %>
<div class="form-control mt-1 w-full max-w-xs">
<label class="label">
<span class="label-text">Name</span>
@@ -45,6 +45,7 @@
</div>
<div class="form-footer">
<%= f.button "Submit", class: "btn btn-primary", data: { turbo: false } %>
<%= f.submit "Create Volume", class: "btn btn-primary" %>
<%= link_to "Cancel", project_volumes_path(volume.project), class: "btn btn-ghost", data: { turbo_frame: "volumes" } %>
</div>
<% end %>
+5 -5
View File
@@ -1,3 +1,4 @@
<%= turbo_frame_tag "volumes" do %>
<div class="space-y-4">
<div class="text-base-content/50">
Volumes are a way to persist data across container restarts. In general, you should use a database for this.
@@ -5,12 +6,10 @@
</div>
<div>
<%= turbo_frame_tag "new_volume" do %>
<%= link_to "Create Volume", new_project_volume_path(project), class: "btn btn-primary btn-outline" %>
<% end %>
<%= link_to "Create Volume", new_project_volume_path(project), class: "btn btn-primary btn-outline", data: { turbo_frame: "volumes" } %>
</div>
<% if @project.volumes.any? %>
<% if project.volumes.any? %>
<div>
<table class="table">
<thead>
@@ -24,9 +23,10 @@
</tr>
</thead>
<tbody>
<%= render partial: "projects/volumes/volume", collection: @project.volumes, as: :volume %>
<%= render partial: "projects/volumes/volume", collection: project.volumes, as: :volume %>
</tbody>
</table>
</div>
<% end %>
</div>
<% end %>
+1 -1
View File
@@ -8,6 +8,6 @@
"error"
end
%>
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
<%= volume.status %>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
<%= turbo_frame_tag "new_volume" do %>
<%= turbo_frame_tag "volumes" do %>
<h3 class="text-lg font-bold">New Volume</h3>
<%= render "projects/volumes/form", volume: @volume %>
<% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
</div>
<% end %>
<nav role="navigation" aria-label="Navbar" class="topbar-wrapper navbar z-10 border-b border-base-200 px-3" data-controller="toggle">
<nav role="navigation" aria-label="Navbar" class="topbar-wrapper navbar z-10 border-b border-base-200 px-3">
<%= render "shared/left_nav" %>
<%= render "shared/right_nav" %>
</nav>
+3 -3
View File
@@ -1,6 +1,6 @@
<div class="ml-auto">
<div class="dropdown ml-auto dropdown-end dropdown-bottom">
<label tabindex="0" class="btn btn-ghost rounded-btn px-1.5 hover:bg-base-content/20">
<div class="dropdown ml-auto dropdown-end dropdown-bottom" data-controller="dropdown">
<label class="btn btn-ghost rounded-btn px-1.5 hover:bg-base-content/20" data-action="click->dropdown#toggle">
<div aria-label="Avatar photo" class="avatar">
<div class="mask mask-squircle" style="width: 30px; height: 30px">
<%= image_tag avatar_path(current_user, size: 40) %>
@@ -14,7 +14,7 @@
<%#= link_to t(".api_tokens"), api_tokens_path, class: 'text-gray-800 bg-white hover:bg-primary-50 dark:text-gray-50 dark:bg-gray-800 dark:hover:bg-primary-800 transition ease-in-out duration-200 whitespace-nowrap no-underline block px-6 py-3' if Jumpstart.config.payments_enabled? %>
<%= link_to edit_user_registration_path do %>
<iconify-icon icon="lucide:user" height="16"></iconify-icon>
Account
Profile
<% end %>
<%= link_to account_users_path do %>
<iconify-icon icon="lucide:users" height="16"></iconify-icon>
+4 -4
View File
@@ -11,7 +11,7 @@ Avo.configure do |config|
# config.mount_avo_engines = true
# Where should the user be redirected when visiting the `/avo` url
# config.home_path = nil
config.home_path = '/admin/dashboard'
## == Licensing ==
# config.license_key = ENV['AVO_LICENSE_KEY']
@@ -23,9 +23,9 @@ Avo.configure do |config|
## == Authentication ==
config.current_user_method = :current_user
# config.authenticate_with do
# current_user.present? && current_user.admin?
# end
config.authenticate_with do
current_user.present? && current_user.admin?
end
## == Authorization ==
# config.is_admin_method = :is_admin
+49
View File
@@ -134,6 +134,55 @@ Devise.setup do |config|
# Send a notification email when the user's password is changed.
# config.send_password_change_notification = false
# ==> Configuration for :invitable
# The period the generated invitation token is valid.
# After this period, the invited resource won't be able to accept the invitation.
# When invite_for is 0 (the default), the invitation won't expire.
# config.invite_for = 2.weeks
# Number of invitations users can send.
# - If invitation_limit is nil, there is no limit for invitations, users can
# send unlimited invitations, invitation_limit column is not used.
# - If invitation_limit is 0, users can't send invitations by default.
# - If invitation_limit n > 0, users can send n invitations.
# You can change invitation_limit column for some users so they can send more
# or less invitations, even with global invitation_limit = 0
# Default: nil
# config.invitation_limit = 5
# The key to be used to check existing users when sending an invitation
# and the regexp used to test it when validate_on_invite is not set.
# config.invite_key = { email: /\A[^@]+@[^@]+\z/ }
# config.invite_key = { email: /\A[^@]+@[^@]+\z/, username: nil }
# Ensure that invited record is valid.
# The invitation won't be sent if this check fails.
# Default: false
# config.validate_on_invite = true
# Resend invitation if user with invited status is invited again
# Default: true
# config.resend_invitation = false
# The class name of the inviting model. If this is nil,
# the #invited_by association is declared to be polymorphic.
# Default: nil
# config.invited_by_class_name = 'User'
# The foreign key to the inviting model (if invited_by_class_name is set)
# Default: :invited_by_id
# config.invited_by_foreign_key = :invited_by_id
# The column name used for counter_cache column. If this is nil,
# the #invited_by association is declared without counter_cache.
# Default: nil
# config.invited_by_counter_cache = :invitations_count
# Auto-login after the user accepts the invite. If this is false,
# the user will need to manually log in after accepting the invite.
# Default: true
# config.allow_insecure_sign_in_after_accept = false
# ==> Configuration for :confirmable
# A period that the user is allowed to access the website even without
# confirming their account. For instance, if set to 2.days, the user will be
+31
View File
@@ -0,0 +1,31 @@
en:
devise:
failure:
invited: "You have a pending invitation, accept it to finish creating your account."
invitations:
send_instructions: "An invitation email has been sent to %{email}."
invitation_token_invalid: "The invitation token provided is not valid!"
updated: "Your password was set successfully. You are now signed in."
updated_not_active: "Your password was set successfully."
no_invitations_remaining: "No invitations remaining"
invitation_removed: "Your invitation was removed."
new:
header: "Send invitation"
submit_button: "Send an invitation"
edit:
header: "Set your password"
submit_button: "Set my password"
mailer:
invitation_instructions:
subject: "Invitation instructions"
hello: "Hello %{email}"
someone_invited_you: "Someone has invited you to %{url}, you can accept it through the link below."
accept: "Accept invitation"
accept_until: "This invitation will be due in %{due_date}."
ignore: "If you don't want to accept the invitation, please ignore this email. Your account won't be created until you access the link above and set your password."
time:
formats:
devise:
mailer:
invitation_instructions:
accept_until_format: "%B %d, %Y %I:%M %p"
+10 -1
View File
@@ -64,7 +64,12 @@ Rails.application.routes.draw do
Avo::Engine.routes.draw do
# This route is not protected, secure it with authentication if needed.
get "dashboard", to: "tools#dashboard", as: :dashboard
resource :impersonation, only: [ :create, :destroy ]
resource :impersonation, only: [ :destroy ] do
post :create, on: :collection, action: :create
get :create, on: :collection, action: :create, as: :start
end
resource :password_reset, only: [ :create ]
resource :promote_to_admin, only: [ :create, :destroy ]
end
end
resources :accounts, only: [ :create ] do
@@ -145,6 +150,7 @@ Rails.application.routes.draw do
end
resources :project_forks, only: %i[index edit create], module: :projects
resources :volumes, only: %i[index new create destroy], module: :projects
resources :notifiers, only: %i[index new create edit update destroy], module: :projects
resources :processes, only: %i[index show create destroy], module: :projects
resources :services, only: %i[index new create destroy update show], module: :projects do
resource :resource_constraint, only: %i[show new create update destroy], module: :services
@@ -201,6 +207,9 @@ Rails.application.routes.draw do
resources :notifications, only: [ :index ]
resources :announcements, only: [ :index ]
devise_for :users, controllers: { omniauth_callbacks: "users/omniauth_callbacks", registrations: "users/registrations", sessions: "users/sessions" }
resource :password_change, only: [ :show, :update ], controller: "password_change"
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
-1
View File
@@ -109,7 +109,6 @@ module.exports = {
],
},
safelist: [
'bg-success/10', 'text-success',
{
pattern: /text-(gray|red|green|yellow|blue|purple|cyan)-(100|300|400|500)/ // LogColorsHelper uses custom tailwind colors
}
@@ -0,0 +1,24 @@
class DeviseInvitableAddToUsers < ActiveRecord::Migration[7.2]
def up
change_table :users do |t|
t.string :invitation_token
t.datetime :invitation_created_at
t.datetime :invitation_sent_at
t.datetime :invitation_accepted_at
t.integer :invitation_limit
t.references :invited_by, polymorphic: true
t.integer :invitations_count, default: 0
t.index :invitation_token, unique: true # for invitable
t.index :invited_by_id
end
add_column :users, :password_change_required, :boolean, default: false
end
def down
remove_column :users, :password_change_required, :boolean, default: false
change_table :users do |t|
t.remove_references :invited_by, polymorphic: true
t.remove :invitations_count, :invitation_limit, :invitation_sent_at, :invitation_accepted_at, :invitation_token, :invitation_created_at
end
end
end
@@ -0,0 +1,13 @@
class CreateNotifiers < ActiveRecord::Migration[7.2]
def change
create_table :notifiers do |t|
t.references :project, null: false, foreign_key: true
t.string :name, null: false
t.integer :provider_type, null: false, default: 0
t.string :webhook_url, null: false
t.boolean :enabled, null: false, default: true
t.timestamps
end
end
end
@@ -0,0 +1,24 @@
class AddSlugToProjects < ActiveRecord::Migration[7.2]
def up
add_column :projects, :slug, :string
Project.find_each do |project|
project.slug = if Project.exists?(slug: project.name)
SecureRandom.uuid[0..7]
else
project.name
end
project.save!(validate: false)
end
change_column_null :projects, :slug, false
add_index :projects, :slug, unique: true
remove_index :services, :project_id
add_index :services, [ :project_id, :name ], unique: true
end
def down
remove_column :projects, :slug
add_index :services, :project_id
remove_index :services, [ :project_id, :name ]
end
end
@@ -0,0 +1,5 @@
class AddAutoManagedToDomains < ActiveRecord::Migration[7.2]
def change
add_column :domains, :auto_managed, :boolean, default: false
end
end
Generated
+29 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
ActiveRecord::Schema[7.2].define(version: 2026_01_25_233309) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -210,6 +210,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
t.datetime "updated_at", null: false
t.integer "status", default: 0
t.string "status_reason"
t.boolean "auto_managed", default: false
t.index ["service_id"], name: "index_domains_on_service_id"
end
@@ -432,6 +433,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient"
end
create_table "notifiers", force: :cascade do |t|
t.bigint "project_id", null: false
t.string "name", null: false
t.integer "provider_type", default: 0, null: false
t.string "webhook_url", null: false
t.boolean "enabled", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["project_id"], name: "index_notifiers_on_project_id"
end
create_table "oauth_access_grants", force: :cascade do |t|
t.bigint "resource_owner_id", null: false
t.bigint "application_id", null: false
@@ -544,8 +556,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
t.integer "project_fork_status", default: 0
t.string "namespace", null: false
t.boolean "managed_namespace", default: true
t.string "slug", null: false
t.index ["cluster_id"], name: "index_projects_on_cluster_id"
t.index ["name"], name: "index_projects_on_name"
t.index ["slug"], name: "index_projects_on_slug", unique: true
end
create_table "providers", force: :cascade do |t|
@@ -612,7 +626,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
t.datetime "updated_at", null: false
t.text "description"
t.jsonb "pod_yaml"
t.index ["project_id"], name: "index_services_on_project_id"
t.index ["project_id", "name"], name: "index_services_on_project_id_and_name", unique: true
end
create_table "sso_providers", force: :cascade do |t|
@@ -683,7 +697,19 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
t.boolean "admin", default: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "invitation_token"
t.datetime "invitation_created_at"
t.datetime "invitation_sent_at"
t.datetime "invitation_accepted_at"
t.integer "invitation_limit"
t.string "invited_by_type"
t.bigint "invited_by_id"
t.integer "invitations_count", default: 0
t.boolean "password_change_required", default: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["invitation_token"], name: "index_users_on_invitation_token", unique: true
t.index ["invited_by_id"], name: "index_users_on_invited_by_id"
t.index ["invited_by_type", "invited_by_id"], name: "index_users_on_invited_by"
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
@@ -721,6 +747,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
add_foreign_key "environment_variables", "projects"
add_foreign_key "favorites", "accounts"
add_foreign_key "favorites", "users"
add_foreign_key "notifiers", "projects"
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id"
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
+138 -14
View File
@@ -3,7 +3,6 @@
<head>
<title>Page Not Found (404)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<style>
:root {
--primary: #167bff;
@@ -12,32 +11,157 @@
--base-content: #dcebfa;
}
body {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body.error-page {
background-color: var(--base-100);
color: var(--base-content);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding-left: 1rem;
padding-right: 1rem;
}
.content-wrapper {
max-width: 56rem;
width: 100%;
}
.grid {
display: grid;
gap: 3rem;
align-items: center;
}
.content-section > * + * {
margin-top: 1.5rem;
}
.error-code {
font-size: 6rem;
line-height: 1;
font-weight: 700;
color: #167bff;
}
.error-title {
font-size: 2.25rem;
line-height: 1.25;
font-weight: 700;
color: var(--base-content);
}
.button-group {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.btn-primary {
display: inline-block;
color: white;
font-weight: 600;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
transition: opacity 200ms;
background-color: #167bff;
text-decoration: none;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
display: inline-block;
font-weight: 600;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
transition: background-color 200ms;
background-color: var(--base-200);
color: var(--base-content);
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn-secondary:hover {
background-color: #2d343d;
}
.illustration-section {
display: flex;
justify-content: center;
}
.illustration {
width: 100%;
max-width: 28rem;
}
@media (min-width: 640px) {
.container {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.error-title {
font-size: 3rem;
line-height: 1;
}
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.illustration-section {
justify-content: flex-end;
}
}
@media (min-width: 1024px) {
.container {
padding-left: 2rem;
padding-right: 2rem;
}
.error-title {
font-size: 3.75rem;
}
}
</style>
</head>
<body>
<div class="min-h-screen flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="max-w-4xl w-full">
<div class="grid md:grid-cols-2 gap-12 items-center">
<body class="error-page">
<div class="container">
<div class="content-wrapper">
<div class="grid">
<!-- Left side - Content -->
<div class="space-y-6">
<div class="text-8xl font-bold" style="color: #167bff;">404</div>
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight" style="color: var(--base-content);">
<div class="content-section">
<div class="error-code">404</div>
<h1 class="error-title">
Sorry,<br>
we can't find<br>
that page.
</h1>
<div class="flex gap-3 flex-wrap">
<a href="/" class="inline-block hover:opacity-90 text-white font-semibold px-8 py-3 rounded-lg transition-all duration-200" style="background-color: #167bff;">
<div class="button-group">
<a href="/" class="btn-primary">
Take me home
</a>
<button onclick="history.back()" class="inline-block hover:bg-[#252b32] font-semibold px-8 py-3 rounded-lg transition-colors duration-200" style="background-color: var(--base-200); color: var(--base-content);">
<button onclick="history.back()" class="btn-secondary">
Go back
</button>
</div>
@@ -45,8 +169,8 @@
</div>
<!-- Right side - Illustration -->
<div class="flex justify-center md:justify-end">
<img src="/images/illustrations/floating.webp" alt="Error illustration" class="w-full max-w-md">
<div class="illustration-section">
<img src="/images/illustrations/floating.webp" alt="Error illustration" class="illustration">
</div>
</div>
</div>
+155 -50
View File
@@ -1,65 +1,170 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>Your browser is not supported (406)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
.rails-default-error-page {
background-color: #EFEFEF;
color: #2E2F30;
text-align: center;
font-family: arial, sans-serif;
margin: 0;
}
:root {
--primary: #167bff;
--base-100: #191e23;
--base-200: #252b32;
--base-content: #dcebfa;
--warning: #f97316;
}
.rails-default-error-page div.dialog {
width: 95%;
max-width: 33em;
margin: 4em auto 0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.rails-default-error-page div.dialog > div {
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #BBB;
border-top: #B00100 solid 4px;
border-top-left-radius: 9px;
border-top-right-radius: 9px;
background-color: white;
padding: 7px 12% 0;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
body.error-page {
background-color: var(--base-100);
color: var(--base-content);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.rails-default-error-page h1 {
font-size: 100%;
color: #730E15;
line-height: 1.5em;
}
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding-left: 1rem;
padding-right: 1rem;
}
.rails-default-error-page div.dialog > p {
margin: 0 0 1em;
padding: 1em;
background-color: #F7F7F7;
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #999;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top-color: #DADADA;
color: #666;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
.content-wrapper {
max-width: 56rem;
width: 100%;
}
.grid {
display: grid;
gap: 3rem;
align-items: center;
}
.content-section > * + * {
margin-top: 1.5rem;
}
.error-code {
font-size: 6rem;
line-height: 1;
font-weight: 700;
color: #f97316;
}
.error-title {
font-size: 2.25rem;
line-height: 1.25;
font-weight: 700;
color: var(--base-content);
}
.error-description {
font-size: 1.125rem;
line-height: 1.75rem;
color: var(--base-content);
opacity: 0.8;
}
.button-group {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.btn-primary {
display: inline-block;
color: white;
font-weight: 600;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
transition: opacity 200ms;
background-color: #f97316;
text-decoration: none;
}
.btn-primary:hover {
opacity: 0.9;
}
.illustration-section {
display: flex;
justify-content: center;
}
.illustration {
width: 100%;
max-width: 28rem;
}
@media (min-width: 640px) {
.container {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.error-title {
font-size: 3rem;
line-height: 1;
}
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.illustration-section {
justify-content: flex-end;
}
}
@media (min-width: 1024px) {
.container {
padding-left: 2rem;
padding-right: 2rem;
}
.error-title {
font-size: 3.75rem;
}
}
</style>
</head>
<body class="rails-default-error-page">
<!-- This file lives in public/406-unsupported-browser.html -->
<div class="dialog">
<div>
<h1>Your browser is not supported.</h1>
<p>Please upgrade your browser to continue.</p>
<body class="error-page">
<div class="container">
<div class="content-wrapper">
<div class="grid">
<!-- Left side - Content -->
<div class="content-section">
<div class="error-code">406</div>
<h1 class="error-title">
Sorry,<br>
your browser<br>
is not supported.
</h1>
<p class="error-description">
Please upgrade your browser to continue.
</p>
<div class="button-group">
<a href="/" class="btn-primary">
Take me home
</a>
</div>
</div>
<!-- Right side - Illustration -->
<div class="illustration-section">
<img src="/images/illustrations/design_2.webp" alt="Error illustration" class="illustration">
</div>
</div>
</div>
</div>
</body>
+138 -14
View File
@@ -3,7 +3,6 @@
<head>
<title>The change you wanted was rejected (422)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<style>
:root {
--primary: #167bff;
@@ -13,32 +12,157 @@
--warning: #fbbf24;
}
body {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body.error-page {
background-color: var(--base-100);
color: var(--base-content);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding-left: 1rem;
padding-right: 1rem;
}
.content-wrapper {
max-width: 56rem;
width: 100%;
}
.grid {
display: grid;
gap: 3rem;
align-items: center;
}
.content-section > * + * {
margin-top: 1.5rem;
}
.error-code {
font-size: 6rem;
line-height: 1;
font-weight: 700;
color: #fbbf24;
}
.error-title {
font-size: 2.25rem;
line-height: 1.25;
font-weight: 700;
color: var(--base-content);
}
.button-group {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.btn-primary {
display: inline-block;
color: rgb(17, 24, 39);
font-weight: 600;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
transition: opacity 200ms;
background-color: #fbbf24;
text-decoration: none;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
display: inline-block;
font-weight: 600;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
transition: background-color 200ms;
background-color: var(--base-200);
color: var(--base-content);
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn-secondary:hover {
background-color: #2d343d;
}
.illustration-section {
display: flex;
justify-content: center;
}
.illustration {
width: 100%;
max-width: 28rem;
}
@media (min-width: 640px) {
.container {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.error-title {
font-size: 3rem;
line-height: 1;
}
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.illustration-section {
justify-content: flex-end;
}
}
@media (min-width: 1024px) {
.container {
padding-left: 2rem;
padding-right: 2rem;
}
.error-title {
font-size: 3.75rem;
}
}
</style>
</head>
<body>
<div class="min-h-screen flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="max-w-4xl w-full">
<div class="grid md:grid-cols-2 gap-12 items-center">
<body class="error-page">
<div class="container">
<div class="content-wrapper">
<div class="grid">
<!-- Left side - Content -->
<div class="space-y-6">
<div class="text-8xl font-bold" style="color: #fbbf24;">422</div>
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight" style="color: var(--base-content);">
<div class="content-section">
<div class="error-code">422</div>
<h1 class="error-title">
Sorry,<br>
that wasn't<br>
accepted.
</h1>
<div class="flex gap-3 flex-wrap">
<a href="/" class="inline-block hover:opacity-90 text-gray-900 font-semibold px-8 py-3 rounded-lg transition-all duration-200" style="background-color: #fbbf24;">
<div class="button-group">
<a href="/" class="btn-primary">
Take me home
</a>
<button onclick="history.back()" class="inline-block hover:bg-[#252b32] font-semibold px-8 py-3 rounded-lg transition-colors duration-200" style="background-color: var(--base-200); color: var(--base-content);">
<button onclick="history.back()" class="btn-secondary">
Go back
</button>
</div>
@@ -46,8 +170,8 @@
</div>
<!-- Right side - Illustration -->
<div class="flex justify-center md:justify-end">
<img src="/images/illustrations/design_2.webp" alt="Error illustration" class="w-full max-w-md">
<div class="illustration-section">
<img src="/images/illustrations/design_2.webp" alt="Error illustration" class="illustration">
</div>
</div>
</div>
+138 -14
View File
@@ -3,7 +3,6 @@
<head>
<title>Something Went Wrong (500)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<style>
:root {
--primary: #167bff;
@@ -13,32 +12,157 @@
--error: #f31260;
}
body {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body.error-page {
background-color: var(--base-100);
color: var(--base-content);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding-left: 1rem;
padding-right: 1rem;
}
.content-wrapper {
max-width: 56rem;
width: 100%;
}
.grid {
display: grid;
gap: 3rem;
align-items: center;
}
.content-section > * + * {
margin-top: 1.5rem;
}
.error-code {
font-size: 6rem;
line-height: 1;
font-weight: 700;
color: #f31260;
}
.error-title {
font-size: 2.25rem;
line-height: 1.25;
font-weight: 700;
color: var(--base-content);
}
.button-group {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.btn-primary {
display: inline-block;
color: white;
font-weight: 600;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
transition: opacity 200ms;
background-color: #f31260;
text-decoration: none;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
display: inline-block;
font-weight: 600;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
transition: background-color 200ms;
background-color: var(--base-200);
color: var(--base-content);
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn-secondary:hover {
background-color: #2d343d;
}
.illustration-section {
display: flex;
justify-content: center;
}
.illustration {
width: 100%;
max-width: 28rem;
}
@media (min-width: 640px) {
.container {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.error-title {
font-size: 3rem;
line-height: 1;
}
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.illustration-section {
justify-content: flex-end;
}
}
@media (min-width: 1024px) {
.container {
padding-left: 2rem;
padding-right: 2rem;
}
.error-title {
font-size: 3.75rem;
}
}
</style>
</head>
<body>
<div class="min-h-screen flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="max-w-4xl w-full">
<div class="grid md:grid-cols-2 gap-12 items-center">
<body class="error-page">
<div class="container">
<div class="content-wrapper">
<div class="grid">
<!-- Left side - Content -->
<div class="space-y-6">
<div class="text-8xl font-bold" style="color: #f31260;">500</div>
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight" style="color: var(--base-content);">
<div class="content-section">
<div class="error-code">500</div>
<h1 class="error-title">
Sorry,<br>
something<br>
went wrong.
</h1>
<div class="flex gap-3 flex-wrap">
<a href="/" class="inline-block hover:opacity-90 text-white font-semibold px-8 py-3 rounded-lg transition-all duration-200" style="background-color: #f31260;">
<div class="button-group">
<a href="/" class="btn-primary">
Take me home
</a>
<button onclick="history.back()" class="inline-block hover:bg-[#252b32] font-semibold px-8 py-3 rounded-lg transition-colors duration-200" style="background-color: var(--base-200); color: var(--base-content);">
<button onclick="history.back()" class="btn-secondary">
Go back
</button>
</div>
@@ -46,8 +170,8 @@
</div>
<!-- Right side - Illustration -->
<div class="flex justify-center md:justify-end">
<img src="/images/illustrations/rover.webp" alt="Error illustration" class="w-full max-w-md">
<div class="illustration-section">
<img src="/images/illustrations/rover.webp" alt="Error illustration" class="illustration">
</div>
</div>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

@@ -0,0 +1,56 @@
require 'rails_helper'
RSpec.describe Domains::AttachAutoManagedDomain do
let(:project) { create(:project) }
describe '.execute' do
context 'when auto dns is enabled and service is public web service' do
let(:service) { create(:service, project: project, allow_public_networking: true, service_type: :web_service) }
before do
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(true)
end
it 'creates an auto-managed domain' do
expect { described_class.execute(service: service) }.to change { service.domains.count }.by(1)
domain = service.domains.last
expect(domain.auto_managed).to be true
expect(domain.domain_name).to eq("#{service.name}-#{project.slug}.oncanine.run")
end
it 'does not create duplicate auto-managed domains' do
described_class.execute(service: service)
expect { described_class.execute(service: service) }.not_to change { service.domains.count }
end
end
context 'when auto dns is disabled' do
let(:service) { create(:service, project: project, allow_public_networking: true, service_type: :web_service) }
before do
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(false)
end
it 'does not create a domain' do
expect { described_class.execute(service: service) }.not_to change { Domain.count }
end
end
context 'when service is not public or not a web service' do
before do
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(true)
end
it 'does not create a domain for non-public services' do
service = create(:service, project: project, allow_public_networking: false, service_type: :web_service)
expect { described_class.execute(service: service) }.not_to change { Domain.count }
end
it 'does not create a domain for background services' do
service = create(:service, :background_service, project: project, allow_public_networking: true)
expect { described_class.execute(service: service) }.not_to change { Domain.count }
end
end
end
end
+68
View File
@@ -0,0 +1,68 @@
require 'rails_helper'
RSpec.describe Domains::Destroy do
let(:project) { create(:project) }
let(:service) { create(:service, project: project) }
describe '.execute' do
it 'destroys the domain' do
domain = create(:domain, service: service)
expect { described_class.execute(domain: domain) }.to change { Domain.count }.by(-1)
end
context 'when domain is auto_managed and dns is enabled' do
let(:dns_client) { instance_double(Dns::Cloudflare, domain: 'oncanine.run') }
before do
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(true)
allow(Dns::Client).to receive(:default).and_return(dns_client)
end
it 'deletes the DNS record and destroys the domain' do
domain = create(:domain, service: service, domain_name: 'test-app.oncanine.run', auto_managed: true)
expect(dns_client).to receive(:delete_record).with(subdomain: 'test-app')
described_class.execute(domain: domain)
expect(Domain.exists?(domain.id)).to be false
end
it 'still destroys the domain if DNS deletion fails' do
domain = create(:domain, service: service, domain_name: 'test-app.oncanine.run', auto_managed: true)
allow(dns_client).to receive(:delete_record).and_raise(Dns::Client::Error, 'API error')
expect { described_class.execute(domain: domain) }.to change { Domain.count }.by(-1)
end
end
context 'when domain is not auto_managed' do
before do
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(true)
end
it 'does not attempt DNS cleanup' do
domain = create(:domain, service: service, auto_managed: false)
expect(Dns::Client).not_to receive(:default)
described_class.execute(domain: domain)
end
end
context 'when dns is disabled' do
before do
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(false)
end
it 'does not attempt DNS cleanup' do
domain = create(:domain, service: service, auto_managed: true)
expect(Dns::Client).not_to receive(:default)
described_class.execute(domain: domain)
end
end
end
end
+14 -6
View File
@@ -1,21 +1,21 @@
require 'rails_helper'
RSpec.describe Networks::CheckDns do
let(:cluster) { create(:cluster) }
let(:cluster) { create(:cluster, cluster_type: :k3s) }
let(:user) { create(:user) }
let(:project) { create(:project, cluster: cluster) }
let(:service) { create(:service, project: project) }
let(:connection) { K8::Connection.new(cluster, user) }
let(:ingress) { K8::Stateless::Ingress.new(service).connect(connection) }
describe '.infer_expected_dns' do
describe 'Dns::Utils.infer_expected_hostname' do
context 'when ingress returns public IP' do
before do
allow(ingress).to receive(:hostname).and_return({ value: '8.8.8.8', type: :ip_address })
end
it 'returns the IP' do
expect(described_class.infer_expected_dns(ingress, connection)).to eq({ value: "8.8.8.8", type: :ip_address })
expect(Dns::Utils.infer_expected_hostname(ingress, connection)).to eq({ value: "8.8.8.8", type: :ip_address })
end
end
@@ -25,8 +25,16 @@ RSpec.describe Networks::CheckDns do
allow(Resolv).to receive(:getaddress).with('example.com').and_return('1.2.3.4')
end
it 'resolves and returns public IP' do
expect(described_class.infer_expected_dns(ingress, connection)).to eq({ value: "1.2.3.4", type: :ip_address })
context 'when cluster is a single node cluster' do
it 'resolves and returns public IP' do
expect(Dns::Utils.infer_expected_hostname(ingress, connection)).to eq({ value: "1.2.3.4", type: :ip_address })
end
end
context 'when cluster is not a single node cluster' do
let(:cluster) { create(:cluster, cluster_type: :k8s) }
it 'raises an error' do
expect { Dns::Utils.infer_expected_hostname(ingress, connection) }.to raise_error("Private IP address detected for cluster type: k8s")
end
end
end
@@ -49,7 +57,7 @@ RSpec.describe Networks::CheckDns do
end
it 'returns the hostname IP' do
expect(described_class.infer_expected_dns(ingress, connection)).to(eq({ value: "1.2.3.4", type: :ip_address }))
expect(Dns::Utils.infer_expected_hostname(ingress, connection)).to(eq({ value: "1.2.3.4", type: :ip_address }))
end
end
end
+27 -1
View File
@@ -1,8 +1,9 @@
require 'rails_helper'
RSpec.describe Services::Create do
let(:project) { create(:project) }
it 'saves the service with associations' do
project = create(:project)
service = build(:service, :cron_job, project: project)
result = described_class.call(service, { service: {} })
@@ -11,4 +12,29 @@ RSpec.describe Services::Create do
expect(service).to be_persisted
expect(service.cron_schedule).to be_persisted
end
context 'when auto dns is enabled' do
before do
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(true)
end
it 'creates an auto-managed domain for public web services' do
service = build(:service, project: project, allow_public_networking: true, service_type: :web_service)
result = described_class.call(service, { service: {} })
expect(result).to be_success
expect(service.domains.count).to eq(1)
expect(service.domains.first.auto_managed).to be true
end
it 'does not create an auto-managed domain for non-public services' do
service = build(:service, project: project, allow_public_networking: false, service_type: :web_service)
result = described_class.call(service, { service: {} })
expect(result).to be_success
expect(service.domains.count).to eq(0)
end
end
end
+43
View File
@@ -0,0 +1,43 @@
require 'rails_helper'
RSpec.describe Services::Update do
let(:project) { create(:project) }
describe '.execute' do
it 'updates the service and marks it as updated' do
service = create(:service, project: project, replicas: 1)
params = ActionController::Parameters.new({ service: { replicas: 3 } })
result = described_class.execute(service: service, params: params)
expect(result).to be_success
expect(service.reload.replicas).to eq(3)
expect(service.status).to eq('updated')
end
context 'when auto dns is enabled' do
before do
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(true)
end
it 'creates an auto-managed domain when public networking is turned on' do
service = create(:service, project: project, allow_public_networking: false, service_type: :web_service)
params = ActionController::Parameters.new({ service: { allow_public_networking: true } })
expect { described_class.execute(service: service, params: params) }
.to change { service.domains.count }.by(1)
expect(service.domains.last.auto_managed).to be true
end
it 'does not create a domain when public networking was already on' do
service = create(:service, project: project, allow_public_networking: true, service_type: :web_service)
create(:domain, service: service, auto_managed: true)
params = ActionController::Parameters.new({ service: { replicas: 2 } })
expect { described_class.execute(service: service, params: params) }
.not_to change { service.domains.count }
end
end
end
end
+1
View File
@@ -3,6 +3,7 @@
# Table name: domains
#
# id :bigint not null, primary key
# auto_managed :boolean default(FALSE)
# domain_name :string not null
# status :integer default("checking_dns")
# status_reason :string

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