deployed
Advanced tools
| module Deployed | ||
| class LogOutputController < ApplicationController | ||
| include ActionController::Live | ||
| before_action :set_headers | ||
| def index | ||
| thread_exit_flag = false | ||
| thread = Thread.new do | ||
| File.open(current_log_file, 'r') do |file| | ||
| while true | ||
| IO.select([file]) | ||
| found_deployed = false | ||
| file.each_line do |line| | ||
| # Check the exit flag | ||
| if thread_exit_flag | ||
| break | ||
| end | ||
| css_class = if line.include?('[Deployed]') | ||
| 'text-slate-400' | ||
| else | ||
| 'text-green-400' | ||
| end | ||
| sse.write("<div class='#{css_class}'>#{line.strip}</div>", event: 'message') | ||
| if line.include?("[Deployed Rails] End") | ||
| found_deployed = true | ||
| break | ||
| end | ||
| end | ||
| if found_deployed || thread_exit_flag | ||
| break | ||
| end | ||
| end | ||
| end | ||
| end | ||
| begin | ||
| thread.join | ||
| rescue ActionController::Live::ClientDisconnected | ||
| logger.info 'Client Disconnected' | ||
| ensure | ||
| # Set the exit flag to true to signal the thread to exit | ||
| thread_exit_flag = true | ||
| sse.close | ||
| response.stream.close | ||
| end | ||
| end | ||
| private | ||
| def set_headers | ||
| response.headers['Content-Type'] = 'text/event-stream' | ||
| response.headers['Last-Modified'] = Time.now.httpdate | ||
| end | ||
| def sse | ||
| @sse ||= SSE.new(response.stream, event: 'Stream Started') | ||
| end | ||
| end | ||
| end |
| # lib/tasks/deployed.rake | ||
| namespace :deployed do | ||
| desc "Execute a Kamal command and log its output" | ||
| task :execute_and_log, [:command] => :environment do |task, args| | ||
| command = args[:command] | ||
| unless command | ||
| puts "Please provide a Kamal command. Usage: rake deployed:execute_and_log[command]" | ||
| next | ||
| end | ||
| log_file = Rails.root.join(Deployed::DIRECTORY, 'deployments/current.log') | ||
| File.open(log_file, 'a') do |file| | ||
| IO.popen("kamal #{command}") do |io| | ||
| start_time = Time.now | ||
| file.puts("[Deployed] > kamal #{command}") | ||
| file.fsync | ||
| io.each_line do |line| | ||
| file.puts line | ||
| file.fsync # Force data to be written to disk immediately | ||
| end | ||
| end_time = Time.now | ||
| file.puts("[Deployed] Finished in #{end_time - start_time} seconds") | ||
| file.puts("[Deployed] End") | ||
| file.fsync | ||
| # Delete lockfile | ||
| File.delete(Rails.root.join(Deployed::DIRECTORY, 'process.lock')) | ||
| end | ||
| end | ||
| end | ||
| end |
@@ -7,27 +7,16 @@ import 'https://cdn.skypack.dev/@hotwired/turbo-rails' | ||
| window.execDeployed = (commandToRun) => { | ||
| // Let the frontend know we're starting | ||
| Alpine.store('process').start() | ||
| const endMarker = '[Deployed Rails] End' | ||
| const outputContainerEl = document.getElementById('deploy-output') | ||
| const spinnerEl = document.getElementById('spinner') | ||
| let outputContainerEl = document.getElementById('deploy-output') | ||
| let spinnerEl = document.getElementById('spinner') | ||
| if (outputContainerEl.innerHTML !== '') { | ||
| outputContainerEl.innerHTML += "<div class='py-2'></div>" | ||
| } | ||
| window.pipeLogs = () => { | ||
| spinnerEl.classList.remove('hidden') | ||
| var source = new EventSource(`/deployed/execute?command=${commandToRun}`) | ||
| window.logEventSource = new EventSource(`/deployed/log_output`) | ||
| source.onmessage = (event) => { | ||
| window.logEventSource.onmessage = (event) => { | ||
| if (!Alpine.store('process').running) { | ||
| source.close() | ||
| window.logEventSource.close() | ||
| } else { | ||
| if (event.data.includes('[Deployed Rails] End transmission')) { | ||
| source.close() | ||
| outputContainerEl.innerHTML += `<div class="text-slate-400 pb-4">Executed: <span class='text-slate-400 font-semibold'>kamal ${commandToRun}</span></div>` | ||
| spinnerEl.classList.add('hidden') | ||
| // Let the frontend know we're done | ||
| Alpine.store('process').stop() | ||
| if (event.data.includes("[Deployed] End")) { | ||
| window.stopPipeLogs() | ||
| } else { | ||
@@ -43,2 +32,42 @@ outputContainerEl.innerHTML += event.data | ||
| window.stopPipeLogs = () => { | ||
| if (typeof(window.logEventSource) !== 'undefined') { | ||
| window.logEventSource.close() | ||
| } | ||
| spinnerEl.classList.add('hidden') | ||
| Alpine.store('process').stop() | ||
| } | ||
| window.execDeployed = (commandToRun) => { | ||
| Alpine.store('process').start() | ||
| let endpoint = `/deployed/execute` | ||
| // Create a data object with your payload (in this case, a command) | ||
| const data = { command: commandToRun } | ||
| // Define the fetch options for the POST request | ||
| const options = { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(data) | ||
| } | ||
| // Perform the POST request using the fetch API | ||
| fetch(endpoint, options) | ||
| .then(response => { | ||
| if (response.ok) { | ||
| outputContainerEl.innerHTML += "<div class='py-2'></div>" | ||
| outputContainerEl.innerHTML += `<div class='text-slate-400'>[Deployed] Command Received: kamal ${commandToRun}</div>` | ||
| window.pipeLogs() | ||
| return response.json(); // Parse the JSON response if needed | ||
| } else { | ||
| throw new Error('Network response was not ok'); | ||
| } | ||
| }) | ||
| .catch(error => { | ||
| console.error('Fetch error:', error) | ||
| }) | ||
| } | ||
| window.abortDeployed = () => { | ||
@@ -51,22 +80,30 @@ // Let the frontend know we're starting | ||
| outputContainerEl.innerHTML += `<div class="text-red-400 py-4">Aborting...</div>` | ||
| outputContainerEl.innerHTML += `<div class="text-red-400">Aborting...</div>` | ||
| var source = new EventSource(`/deployed/cancel`) | ||
| let endpoint = `/deployed/cancel` | ||
| source.onmessage = (event) => { | ||
| if (event.data.includes('[Deployed Rails] End transmission')) { | ||
| source.close() | ||
| const options = { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' } | ||
| } | ||
| spinnerEl.classList.add('hidden') | ||
| // Let the frontend know we're done | ||
| Alpine.store('process').stop() | ||
| Alpine.store('process').resetAbort() | ||
| } else { | ||
| outputContainerEl.innerHTML += event.data | ||
| } | ||
| outputContainerEl.scrollIntoView({ behavior: "smooth", block: "end" }) | ||
| spinnerEl.scrollIntoView({ behavior: "smooth", block: "end" }) | ||
| } | ||
| // Perform the POST request using the fetch API | ||
| fetch(endpoint, options) | ||
| .then(response => { | ||
| if (response.ok) { | ||
| window.stopPipeLogs() | ||
| Alpine.store('process').stop() | ||
| Alpine.store('process').resetAbort() | ||
| return response.json(); // Parse the JSON response if needed | ||
| } else { | ||
| throw new Error('Network response was not ok'); | ||
| } | ||
| }) | ||
| .then(data => { | ||
| console.log(data) | ||
| outputContainerEl.innerHTML += `<div class="text-yellow-400">Aborted process with PID ${data.message}</div>` | ||
| }) | ||
| .catch(error => { | ||
| console.error('Fetch error:', error) | ||
| }) | ||
| } | ||
@@ -73,0 +110,0 @@ |
@@ -728,6 +728,2 @@ /* | ||
| .left-\[-225px\] { | ||
| left: -225px; | ||
| } | ||
| .right-0 { | ||
@@ -783,6 +779,2 @@ right: 0px; | ||
| .ml-4 { | ||
| margin-left: 1rem; | ||
| } | ||
| .mr-2 { | ||
@@ -828,6 +820,2 @@ margin-right: 0.5rem; | ||
| .h-11 { | ||
| height: 2.75rem; | ||
| } | ||
| .h-5 { | ||
@@ -857,6 +845,2 @@ height: 1.25rem; | ||
| .w-11 { | ||
| width: 2.75rem; | ||
| } | ||
| .w-5 { | ||
@@ -886,11 +870,2 @@ width: 1.25rem; | ||
| .max-w-max { | ||
| max-width: -moz-max-content; | ||
| max-width: max-content; | ||
| } | ||
| .max-w-md { | ||
| max-width: 28rem; | ||
| } | ||
| .flex-1 { | ||
@@ -900,10 +875,2 @@ flex: 1 1 0%; | ||
| .flex-auto { | ||
| flex: 1 1 auto; | ||
| } | ||
| .flex-none { | ||
| flex: none; | ||
| } | ||
| .translate-y-0 { | ||
@@ -914,7 +881,2 @@ --tw-translate-y: 0px; | ||
| .translate-y-1 { | ||
| --tw-translate-y: 0.25rem; | ||
| transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); | ||
| } | ||
| .translate-y-4 { | ||
@@ -973,12 +935,2 @@ --tw-translate-y: 1rem; | ||
| .gap-x-1 { | ||
| -moz-column-gap: 0.25rem; | ||
| column-gap: 0.25rem; | ||
| } | ||
| .gap-x-6 { | ||
| -moz-column-gap: 1.5rem; | ||
| column-gap: 1.5rem; | ||
| } | ||
| .gap-x-8 { | ||
@@ -989,6 +941,2 @@ -moz-column-gap: 2rem; | ||
| .gap-y-1 { | ||
| row-gap: 0.25rem; | ||
| } | ||
| .gap-y-4 { | ||
@@ -1004,2 +952,8 @@ row-gap: 1rem; | ||
| .space-x-2 > :not([hidden]) ~ :not([hidden]) { | ||
| --tw-space-x-reverse: 0; | ||
| margin-right: calc(0.5rem * var(--tw-space-x-reverse)); | ||
| margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); | ||
| } | ||
| .space-y-10 > :not([hidden]) ~ :not([hidden]) { | ||
@@ -1017,14 +971,2 @@ --tw-space-y-reverse: 0; | ||
| .space-x-3 > :not([hidden]) ~ :not([hidden]) { | ||
| --tw-space-x-reverse: 0; | ||
| margin-right: calc(0.75rem * var(--tw-space-x-reverse)); | ||
| margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); | ||
| } | ||
| .space-x-2 > :not([hidden]) ~ :not([hidden]) { | ||
| --tw-space-x-reverse: 0; | ||
| margin-right: calc(0.5rem * var(--tw-space-x-reverse)); | ||
| margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); | ||
| } | ||
| .divide-y > :not([hidden]) ~ :not([hidden]) { | ||
@@ -1057,6 +999,2 @@ --tw-divide-y-reverse: 0; | ||
| .rounded-3xl { | ||
| border-radius: 1.5rem; | ||
| } | ||
| .rounded-full { | ||
@@ -1082,2 +1020,6 @@ border-radius: 9999px; | ||
| .border-b-4 { | ||
| border-bottom-width: 4px; | ||
| } | ||
| .border-t { | ||
@@ -1087,10 +1029,2 @@ border-top-width: 1px; | ||
| .border-b-2 { | ||
| border-bottom-width: 2px; | ||
| } | ||
| .border-b-4 { | ||
| border-bottom-width: 4px; | ||
| } | ||
| .border-slate-200 { | ||
@@ -1106,7 +1040,2 @@ --tw-border-opacity: 1; | ||
| .bg-gray-50 { | ||
| --tw-bg-opacity: 1; | ||
| background-color: rgb(249 250 251 / var(--tw-bg-opacity)); | ||
| } | ||
| .bg-gray-500 { | ||
@@ -1279,10 +1208,2 @@ --tw-bg-opacity: 1; | ||
| .pt-\[52px\] { | ||
| padding-top: 52px; | ||
| } | ||
| .pt-\[55px\] { | ||
| padding-top: 55px; | ||
| } | ||
| .pt-\[56px\] { | ||
@@ -1353,7 +1274,2 @@ padding-top: 56px; | ||
| .text-gray-600 { | ||
| --tw-text-opacity: 1; | ||
| color: rgb(75 85 99 / var(--tw-text-opacity)); | ||
| } | ||
| .text-gray-700 { | ||
@@ -1444,8 +1360,2 @@ --tw-text-opacity: 1; | ||
| .shadow-lg { | ||
| --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); | ||
| --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); | ||
| box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); | ||
| } | ||
| .shadow-md { | ||
@@ -1488,10 +1398,2 @@ --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); | ||
| .transition { | ||
| transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; | ||
| transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; | ||
| transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; | ||
| transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); | ||
| transition-duration: 150ms; | ||
| } | ||
| .transition-all { | ||
@@ -1509,6 +1411,2 @@ transition-property: all; | ||
| .duration-150 { | ||
| transition-duration: 150ms; | ||
| } | ||
| .duration-200 { | ||
@@ -1592,12 +1490,2 @@ transition-duration: 200ms; | ||
| .group:hover .group-hover\:bg-white { | ||
| --tw-bg-opacity: 1; | ||
| background-color: rgb(255 255 255 / var(--tw-bg-opacity)); | ||
| } | ||
| .group:hover .group-hover\:text-indigo-600 { | ||
| --tw-text-opacity: 1; | ||
| color: rgb(79 70 229 / var(--tw-text-opacity)); | ||
| } | ||
| @media (min-width: 640px) { | ||
@@ -1647,11 +1535,1 @@ .sm\:col-span-2 { | ||
| } | ||
| @media (min-width: 1024px) { | ||
| .lg\:max-w-2xl { | ||
| max-width: 42rem; | ||
| } | ||
| .lg\:grid-cols-2 { | ||
| grid-template-columns: repeat(2, minmax(0, 1fr)); | ||
| } | ||
| } |
@@ -17,3 +17,48 @@ # frozen_string_literal: true | ||
| end | ||
| def lock_file_path | ||
| Rails.root.join(Deployed::DIRECTORY, 'process.lock') | ||
| end | ||
| def lock_process | ||
| File.open(lock_file_path, 'a') do |file| | ||
| file.puts(Deployed::CurrentExecution.child_pid) | ||
| end | ||
| end | ||
| def release_process | ||
| return unless File.exist?(lock_file_path) | ||
| File.delete(lock_file_path) | ||
| end | ||
| def stored_pid | ||
| return false unless File.exist?(lock_file_path) | ||
| value = File.read(lock_file_path).to_i | ||
| if value.is_a?(Integer) | ||
| value | ||
| else | ||
| false | ||
| end | ||
| end | ||
| def process_running? | ||
| return false unless stored_pid | ||
| begin | ||
| # Send signal 0 to the process to check if it exists | ||
| Process.kill(0, stored_pid) | ||
| true | ||
| rescue Errno::ESRCH | ||
| false | ||
| end | ||
| end | ||
| helper_method :process_running? | ||
| def current_log_file | ||
| Rails.root.join(Deployed::DIRECTORY, 'deployments/current.log') | ||
| end | ||
| end | ||
| end |
@@ -7,79 +7,19 @@ # frozen_string_literal: true | ||
| class ConcurrentProcessRunning < StandardError; end | ||
| skip_forgery_protection | ||
| include ActionController::Live | ||
| before_action :set_headers | ||
| # Endpoint to execute the kamal command | ||
| def execute | ||
| if process_running? | ||
| raise(ConcurrentProcessRunning) | ||
| elsif stored_pid | ||
| release_process | ||
| end | ||
| raise(ConcurrentProcessRunning) if process_running? | ||
| release_process if stored_pid | ||
| File.write(current_log_file, '') | ||
| sse.write( | ||
| "<div class='text-slate-400'>> <span class='text-slate-300 font-semibold'>kamal #{command}</span></div>", | ||
| event: 'message' | ||
| ) | ||
| read_io, write_io = IO.pipe | ||
| # Fork a child process | ||
| Deployed::CurrentExecution.child_pid = fork do | ||
| # Redirect the standard output to the write end of the pipe | ||
| $stdout.reopen(write_io) | ||
| # Execute the command | ||
| exec("kamal #{command}; echo \"[Deployed Rails] End transmission\"") | ||
| exec("bundle exec rake deployed:execute_and_log['#{command}']") | ||
| end | ||
| lock_process | ||
| sse.write( | ||
| "<div class='text-slate-400' data-child-pid=\"#{Deployed::CurrentExecution.child_pid}\"></div>", | ||
| event: 'message' | ||
| ) | ||
| write_io.close | ||
| # Use a separate thread to read and stream the output | ||
| output_thread = Thread.new do | ||
| read_io.each_line do |line| | ||
| output = line.strip | ||
| output = output.gsub('49.13.91.176', '[redacted]') | ||
| text_color_class = 'text-green-400' | ||
| # Hackish way of dealing with error messages at the moment | ||
| if output.include?('[31m') | ||
| text_color_class = 'text-red-500' | ||
| output.gsub!('[31m', '') | ||
| output.gsub!('[0m', '') | ||
| end | ||
| sse.write("<div class='#{text_color_class}'>#{output}</div>", event: 'message') | ||
| end | ||
| # Ensure the response stream and the thread are closed properly | ||
| sse.close | ||
| response.stream.close | ||
| end | ||
| # Ensure that the thread is joined when the execution is complete | ||
| Process.wait | ||
| output_thread.join | ||
| render json: { message: 'OK' } | ||
| rescue ConcurrentProcessRunning | ||
| sse.write( | ||
| "<div class='text-red-500'>Existing process running with PID: #{stored_pid}</div>", | ||
| event: 'message' | ||
| ) | ||
| logger.info 'Existing process running' | ||
| rescue ActionController::Live::ClientDisconnected | ||
| logger.info 'Client Disconnected' | ||
| rescue IOError | ||
| logger.info 'IOError' | ||
| ensure | ||
| sse.close | ||
| response.stream.close | ||
| release_process | ||
| render json: { message: 'EXISTING PROCESS' } | ||
| end | ||
@@ -89,2 +29,3 @@ | ||
| def cancel | ||
| pid = stored_pid | ||
| if process_running? | ||
@@ -94,31 +35,8 @@ # If a process is running, get the PID and attempt to kill it | ||
| Process.kill('TERM', stored_pid) | ||
| sse.write( | ||
| "<div class='text-yellow-400'>Cancelled the process with PID: #{stored_pid}</div>", | ||
| event: 'message' | ||
| ) | ||
| rescue Errno::ESRCH | ||
| ensure | ||
| release_process | ||
| rescue Errno::ESRCH | ||
| sse.write( | ||
| "<div class='text-red-500'>Process with PID #{stored_pid} is not running.</div>", | ||
| event: 'message' | ||
| ) | ||
| end | ||
| else | ||
| sse.write( | ||
| "<div class='text-slate-400'>No process is currently running, nothing to cancel.</div>", | ||
| event: 'message' | ||
| ) | ||
| end | ||
| rescue ActionController::Live::ClientDisconnected | ||
| logger.info 'Client Disconnected' | ||
| rescue IOError | ||
| logger.info 'IOError' | ||
| ensure | ||
| sse.write( | ||
| '[Deployed Rails] End transmission', | ||
| event: 'message' | ||
| ) | ||
| sse.close | ||
| response.stream.close | ||
| release_process | ||
| render json: { message: pid } | ||
| end | ||
@@ -128,55 +46,6 @@ | ||
| def set_headers | ||
| response.headers['Content-Type'] = 'text/event-stream' | ||
| response.headers['Last-Modified'] = Time.now.httpdate | ||
| end | ||
| def sse | ||
| @sse ||= SSE.new(response.stream, event: 'Stream Started') | ||
| end | ||
| def command | ||
| params[:command] | ||
| end | ||
| def lock_file_path | ||
| Rails.root.join(Deployed::DIRECTORY, 'process.lock') | ||
| end | ||
| def lock_process | ||
| File.open(lock_file_path, 'a') do |file| | ||
| file.puts(Deployed::CurrentExecution.child_pid) | ||
| end | ||
| end | ||
| def release_process | ||
| return unless File.exist?(lock_file_path) | ||
| File.delete(lock_file_path) | ||
| end | ||
| def stored_pid | ||
| return false unless File.exist?(lock_file_path) | ||
| value = File.read(lock_file_path).to_i | ||
| if value.is_a?(Integer) | ||
| value | ||
| else | ||
| false | ||
| end | ||
| end | ||
| def process_running? | ||
| return false unless stored_pid | ||
| begin | ||
| # Send signal 0 to the process to check if it exists | ||
| Process.kill(0, stored_pid) | ||
| true | ||
| rescue Errno::ESRCH | ||
| false | ||
| end | ||
| end | ||
| end | ||
| end |
@@ -13,3 +13,3 @@ <!DOCTYPE html> | ||
| Alpine.store('process', { | ||
| running: false, | ||
| running: <%= process_running? %>, | ||
| start() { | ||
@@ -51,10 +51,8 @@ this.running = true | ||
| </div> | ||
| <div> | ||
| <span class="text-base text-slate-400">v<%= Deployed::VERSION %></span> | ||
| </div> | ||
| </div> | ||
| </h1> | ||
| </div> | ||
| <% if false %> | ||
| <div class="ml-4"> | ||
| <%= render 'layouts/deployed/nav_menu' %> | ||
| </div> | ||
| <% end %> | ||
| </div> | ||
@@ -91,3 +89,10 @@ </header> | ||
| <%= turbo_frame_tag('deployed-init', src: setup_path, target: '_top') if Deployed::Config.requires_init %> | ||
| <!-- If we refresh the page, we want to see the logs still piping... --> | ||
| <script> | ||
| setTimeout(() => { | ||
| <% if process_running? %>window.pipeLogs()<% end %> | ||
| }, 1000) | ||
| </script> | ||
| </body> | ||
| </html> |
+3
-2
@@ -6,5 +6,6 @@ Deployed::Engine.routes.draw do | ||
| get 'git/uncommitted_check', to: 'git#uncommitted_check' | ||
| get 'execute', to: 'run#execute' | ||
| get 'cancel', to: 'run#cancel' | ||
| post 'execute', to: 'run#execute' | ||
| post 'cancel', to: 'run#cancel' | ||
| get 'log_output', to: 'log_output#index' | ||
| root to: 'welcome#index' | ||
| end |
| module Deployed | ||
| VERSION = "0.1.2" | ||
| VERSION = "0.1.3" | ||
| end |
+29
-12
| # Deployed | ||
| [](https://badge.fury.io/rb/deployed) | ||
| Deployed is a web interface for the deployment library, [Kamal](https://kamal-deploy.org). | ||
| ## Usage | ||
| How to use my plugin. | ||
| Here is a quick video demo: https://x.com/geetfun/status/1716109581619744781?s=20 | ||
| ## Requirements | ||
| Ruby on Rails | ||
| ## Installation | ||
@@ -12,19 +17,31 @@ Add this line to your application's Gemfile: | ||
| ```ruby | ||
| gem "deployed" | ||
| group :development do | ||
| gem 'kamal' | ||
| gem 'deployed' | ||
| end | ||
| ``` | ||
| And then execute: | ||
| ```bash | ||
| $ bundle | ||
| ``` | ||
| ## Usage | ||
| Or install it yourself as: | ||
| ```bash | ||
| $ gem install deployed | ||
| Add the following to your app's routes file: | ||
| ```ruby | ||
| Rails.application.routes.draw do | ||
| if Rails.env.development? && defined?(Deployed) | ||
| mount(Deployed::Engine => '/deployed') | ||
| end | ||
| # Your other routes... | ||
| end | ||
| ``` | ||
| ## Contributing | ||
| Contribution directions go here. | ||
| Next, head to `http://localhost:3000/deployed` | ||
| ## Development | ||
| Run `bin/setup` to bootstrap the development environment. | ||
| To run tests: `bundle exec rake app:test`. Currently there are no tests, but some will be added soon. | ||
| ## License | ||
| The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). |
| <div class="relative"> | ||
| <button type="button" class="inline-flex items-center gap-x-1 text-sm font-semibold leading-6 text-slate-400" aria-expanded="false"> | ||
| <span>Resources</span> | ||
| <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> | ||
| <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" /> | ||
| </svg> | ||
| </button> | ||
| <!-- | ||
| Flyout menu, show/hide based on flyout menu state. | ||
| Entering: "transition ease-out duration-200" | ||
| From: "opacity-0 translate-y-1" | ||
| To: "opacity-100 translate-y-0" | ||
| Leaving: "transition ease-in duration-150" | ||
| From: "opacity-100 translate-y-0" | ||
| To: "opacity-0 translate-y-1" | ||
| --> | ||
| <div class="absolute z-10 mt-5 flex w-screen max-w-max left-[-225px] px-4"> | ||
| <div class="w-screen max-w-md flex-auto overflow-hidden rounded-3xl bg-white text-sm leading-6 shadow-lg ring-1 ring-gray-900/5 lg:max-w-2xl"> | ||
| <div class="grid grid-cols-1 gap-x-6 gap-y-1 p-4 lg:grid-cols-2"> | ||
| <div class="group relative flex gap-x-6 rounded-lg p-4 hover:bg-gray-50"> | ||
| <div class="mt-1 flex h-11 w-11 flex-none items-center justify-center rounded-lg bg-gray-50 group-hover:bg-white"> | ||
| <svg class="h-6 w-6 text-gray-600 group-hover:text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"> | ||
| <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6a7.5 7.5 0 107.5 7.5h-7.5V6z" /> | ||
| <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 10.5H21A7.5 7.5 0 0013.5 3v7.5z" /> | ||
| </svg> | ||
| </div> | ||
| <div> | ||
| <a href="#" class="font-semibold text-gray-900"> | ||
| Documentation | ||
| <span class="absolute inset-0"></span> | ||
| </a> | ||
| <p class="mt-1 text-gray-600">Check out our documentation</p> | ||
| </div> | ||
| </div> | ||
| <div class="group relative flex gap-x-6 rounded-lg p-4 hover:bg-gray-50"> | ||
| <div class="mt-1 flex h-11 w-11 flex-none items-center justify-center rounded-lg bg-gray-50 group-hover:bg-white"> | ||
| <svg class="h-6 w-6 text-gray-600 group-hover:text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"> | ||
| <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 002.25-2.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v2.25A2.25 2.25 0 006 10.5zm0 9.75h2.25A2.25 2.25 0 0010.5 18v-2.25a2.25 2.25 0 00-2.25-2.25H6a2.25 2.25 0 00-2.25 2.25V18A2.25 2.25 0 006 20.25zm9.75-9.75H18a2.25 2.25 0 002.25-2.25V6A2.25 2.25 0 0018 3.75h-2.25A2.25 2.25 0 0013.5 6v2.25a2.25 2.25 0 002.25 2.25z" /> | ||
| </svg> | ||
| </div> | ||
| <div> | ||
| <a href="#" class="font-semibold text-gray-900"> | ||
| Our GitHub Repository | ||
| <span class="absolute inset-0"></span> | ||
| </a> | ||
| <p class="mt-1 text-gray-600">Ipsum Lorem</p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> |
| # desc "Explaining what the task does" | ||
| # task :deployed do | ||
| # # Task goes here | ||
| # end |