diff --git a/.env.dev.example b/.env.dev.example index aa2913d..5a1c85f 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -37,6 +37,7 @@ RESEND_API_KEY= # MAX_CPUS=4.0 # DEFAULT_MEMORY_MB=2048 # MAX_MEMORY_MB=8192 +# DEFAULT_DB_SIZE_LIMIT_BYTES=5368709120 # JOB_TIMEOUT=320 # JOB_MAX_TRIES=3 # DEPLOYMENT_TIMEOUT=300 diff --git a/.env.example b/.env.example index 8e3086b..30f9499 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,7 @@ RESEND_API_KEY= # MAX_CPUS=4.0 # DEFAULT_MEMORY_MB=2048 # MAX_MEMORY_MB=8192 +# DEFAULT_DB_SIZE_LIMIT_BYTES=5368709120 # JOB_TIMEOUT=320 # JOB_MAX_TRIES=3 # DEPLOYMENT_TIMEOUT=300 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 719b361..16ddd4e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,7 +21,7 @@ This document describes the high‑level architecture of /dev/push, how the main - **App**: The app handles all of the user-facing logic (managing teams/projects, authenticating, searching logs...). It communicates with the workers via Redis. - **Workers**: When we create a new deployment, we queue a deploy job using arq (`app/workers/arq.py`). It will start a container, then delegate monitoring to a separate background worker (`app/workers/monitor.py`), before wrapping things back with yet another job. These workers are also used to run certain batch jobs (e.g. deleting a team, cleaning up inactive deployments and their containers). - **Logs**: build and runtime logs are streamed from Loki and served to the user via an SSE endpoint in the app. -- **Runners**: User apps are deployed on one of the runner containers (e.g. `docker/runner/Dockerfile.python-3.12`). They are created in the deploy job (`app/workers/tasks/deploy.py`) and then run a series of commands based on the user configuration. +- **Runners**: User apps are deployed on one of the runner containers (e.g. `docker/runner/Dockerfile.python-3.12`). They are created in the deploy job (`app/workers/tasks/deployment.py`) and then run a series of commands based on the user configuration. - **Reverse proxy**: We have Traefik sitting in front of both app and the deployed runner containers. All routing is done using Traefik labels, and we also maintain environment and branch aliases (e.g. `my-project-env-staging.devpush.app`) using Traefik config files. ## File structure @@ -99,13 +99,13 @@ Notes: #### ARQ - Background jobs for deployments and cleanup. -- Jobs: `deploy_start`, `deploy_finalize`, `deploy_fail`, `cleanup_*`. -- Files: `app/workers/arq.py`, `app/workers/tasks/deploy.py`, `app/workers/tasks/cleanup.py`. +- Jobs: `start_deployment`, `finalize_deployment`, `fail_deployment`, `delete_*`. +- Files: `app/workers/arq.py`, `app/workers/tasks/deployment.py`, `app/workers/tasks/project.py`, `app/workers/tasks/team.py`, `app/workers/tasks/user.py`. #### Monitor - Polls running deployments every ~2s, probes port 8000 over `devpush_runner` network. -- On success enqueues `deploy_finalize`; on failure enqueues `deploy_fail`. +- On success enqueues `finalize_deployment`; on failure enqueues `fail_deployment`. - File: `app/workers/monitor.py`. ### Traefik @@ -134,26 +134,26 @@ Notes: ## Deployment Flow 1) Trigger - - Webhook: GitHub -> `/api/github/webhook` (verify, resolve project) -> create DB record -> enqueue `deploy_start`. - - Manual: user selects commit/env -> create DB record -> enqueue `deploy_start`. + - Webhook: GitHub -> `/api/github/webhook` (verify, resolve project) -> create DB record -> enqueue `start_deployment`. + - Manual: user selects commit/env -> create DB record -> enqueue `start_deployment`. -2) `deploy_start` +2) `start_deployment` - Create runner container (language image, env vars, resource limits, Traefik labels; Alloy tails container logs). - Inside container: clone repo at commit, run optional build/pre‑deploy commands, then start app. - Mark deployment `in_progress`, set `container_id=…`, emit Redis Stream update. 3) Monitor - Probe container IP on `devpush_runner:8000/`. - - On ready -> enqueue `deploy_finalize`. On exit/error -> enqueue `deploy_fail`. + - On ready -> enqueue `finalize_deployment`. On exit/error -> enqueue `fail_deployment`. 4) Finalize: - a) deploy_finalize (success) + a) finalize_deployment (success) - Mark `status=completed`, `conclusion=succeeded`. - Create/update aliases: branch, environment, environment_id. - Regenerate Traefik dynamic config for aliases and custom domains. - - Enqueue `cleanup_inactive_deployments` and emit Redis Stream updates. + - Enqueue `cleanup_inactive_containers` and emit Redis Stream updates. - b) deploy_fail (error) + b) fail_deployment (error) - Stop/remove container if present; mark `conclusion=failed` and emit updates. ## Data Model (Simplified) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 921d2f7..12dba3d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ ### Standard Conventions - All scripts must use `set -Eeuo pipefail` and error traps -- Capture stderr to `SCRIPT_ERR_LOG` (e.g., `/tmp/scriptname_error.log`) +- Capture stderr to `SCRIPT_ERR_LOG` (e.g., `/tmp/scriptname-error.log`) - Source `lib.sh` for common functions (`err`, `run_cmd`, `run_cmd_try`) - Argument parsing: Handle unknown options with `err "Unknown option: $1"; usage; exit 1` diff --git a/ROADMAP.md b/ROADMAP.md index 9678797..04a1004 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,7 @@ - [ ] **Remote nodes**: Ability to add multiple remote nodes to deploy apps. - [ ] **Deployment settings**: Provide more granular rules for deployments (triggers, # concurrent events, commit author, etc). - [ ] **Rate limiting**: Add Traefik protections for auth + sensitive endpoints (env-flagged). +- [ ] **Migrate away from ARQ**: ARQ is deprecated, move over to https://github.com/taskiq-python/taskiq or similar. ## Later diff --git a/app/assets/basecoat.min.js b/app/assets/basecoat.min.js index 8c0a5ac..3cbb314 100644 --- a/app/assets/basecoat.min.js +++ b/app/assets/basecoat.min.js @@ -1 +1 @@ -(()=>{const e={};let t=null;const n=()=>{Object.entries(e).forEach((([e,{selector:t,init:n}])=>{document.querySelectorAll(t).forEach(n)}))},a=t=>{t.nodeType===Node.ELEMENT_NODE&&Object.entries(e).forEach((([e,{selector:n,init:a}])=>{t.matches(n)&&a(t),t.querySelectorAll(n).forEach(a)}))},i=()=>{t||(t=new MutationObserver((e=>{e.forEach((e=>{e.addedNodes.forEach(a)}))})),t.observe(document.body,{childList:!0,subtree:!0}))};window.basecoat={register:(t,n,a)=>{e[t]={selector:n,init:a}},init:t=>{const n=e[t];if(!n)return void console.warn(`Component '${t}' not found in registry`);const a=`data-${t}-initialized`;document.querySelectorAll(`[${a}]`).forEach((e=>{e.removeAttribute(a)})),document.querySelectorAll(n.selector).forEach(n.init)},initAll:()=>{Object.entries(e).forEach((([e,{selector:t}])=>{const n=`data-${e}-initialized`;document.querySelectorAll(`[${n}]`).forEach((e=>{e.removeAttribute(n)}))})),n()},start:i,stop:()=>{t&&(t.disconnect(),t=null)}},document.addEventListener("DOMContentLoaded",(()=>{n(),i()}))})(),(()=>{const e=e=>{const t=e.querySelector(":scope > button"),n=e.querySelector(":scope > [data-popover]"),a=n.querySelector('[role="menu"]');if(!t||!a||!n){const i=[];return t||i.push("trigger"),a||i.push("menu"),n||i.push("popover"),void console.error(`Dropdown menu initialisation failed. Missing element(s): ${i.join(", ")}`,e)}let i=[],o=-1;const r=(e=!0)=>{"false"!==t.getAttribute("aria-expanded")&&(t.setAttribute("aria-expanded","false"),t.removeAttribute("aria-activedescendant"),n.setAttribute("aria-hidden","true"),e&&t.focus(),d(-1))},s=(o=!1)=>{document.dispatchEvent(new CustomEvent("basecoat:popover",{detail:{source:e}})),t.setAttribute("aria-expanded","true"),n.setAttribute("aria-hidden","false"),i=Array.from(a.querySelectorAll('[role^="menuitem"]')).filter((e=>!e.hasAttribute("disabled")&&"true"!==e.getAttribute("aria-disabled"))),i.length>0&&o&&("first"===o?d(0):"last"===o&&d(i.length-1))},d=e=>{if(o>-1&&i[o]&&i[o].classList.remove("active"),o=e,o>-1&&i[o]){const e=i[o];e.classList.add("active"),t.setAttribute("aria-activedescendant",e.id)}else t.removeAttribute("aria-activedescendant")};t.addEventListener("click",(()=>{"true"===t.getAttribute("aria-expanded")?r():s(!1)})),e.addEventListener("keydown",(e=>{const n="true"===t.getAttribute("aria-expanded");if("Escape"===e.key)return void(n&&r());if(!n)return void(["Enter"," "].includes(e.key)?(e.preventDefault(),s(!1)):"ArrowDown"===e.key?(e.preventDefault(),s("first")):"ArrowUp"===e.key&&(e.preventDefault(),s("last")));if(0===i.length)return;let a=o;switch(e.key){case"ArrowDown":e.preventDefault(),a=-1===o?0:Math.min(o+1,i.length-1);break;case"ArrowUp":e.preventDefault(),a=-1===o?i.length-1:Math.max(o-1,0);break;case"Home":e.preventDefault(),a=0;break;case"End":e.preventDefault(),a=i.length-1;break;case"Enter":case" ":return e.preventDefault(),i[o]?.click(),void r()}a!==o&&d(a)})),a.addEventListener("mousemove",(e=>{const t=e.target.closest('[role^="menuitem"]');if(t&&i.includes(t)){const e=i.indexOf(t);e!==o&&d(e)}})),a.addEventListener("mouseleave",(()=>{d(-1)})),a.addEventListener("click",(e=>{e.target.closest('[role^="menuitem"]')&&r()})),document.addEventListener("click",(t=>{e.contains(t.target)||r()})),document.addEventListener("basecoat:popover",(t=>{t.detail.source!==e&&r(!1)})),e.dataset.dropdownMenuInitialized=!0,e.dispatchEvent(new CustomEvent("basecoat:initialized"))};window.basecoat&&window.basecoat.register("dropdown-menu",".dropdown-menu:not([data-dropdown-menu-initialized])",e)})(),(()=>{const e=e=>{const t=e.querySelector(":scope > button"),n=e.querySelector(":scope > [data-popover]");if(!t||!n){const a=[];return t||a.push("trigger"),n||a.push("content"),void console.error(`Popover initialisation failed. Missing element(s): ${a.join(", ")}`,e)}const a=(e=!0)=>{"false"!==t.getAttribute("aria-expanded")&&(t.setAttribute("aria-expanded","false"),n.setAttribute("aria-hidden","true"),e&&t.focus())};t.addEventListener("click",(()=>{"true"===t.getAttribute("aria-expanded")?a():(()=>{document.dispatchEvent(new CustomEvent("basecoat:popover",{detail:{source:e}}));const a=n.querySelector("[autofocus]");a&&n.addEventListener("transitionend",(()=>{a.focus()}),{once:!0}),t.setAttribute("aria-expanded","true"),n.setAttribute("aria-hidden","false")})()})),e.addEventListener("keydown",(e=>{"Escape"===e.key&&a()})),document.addEventListener("click",(t=>{e.contains(t.target)||a()})),document.addEventListener("basecoat:popover",(t=>{t.detail.source!==e&&a(!1)})),e.dataset.popoverInitialized=!0,e.dispatchEvent(new CustomEvent("basecoat:initialized"))};window.basecoat&&window.basecoat.register("popover",".popover:not([data-popover-initialized])",e)})(),(()=>{const e=e=>{const t=e.querySelector(":scope > button"),n=t.querySelector(":scope > span"),a=e.querySelector(":scope > [data-popover]"),i=a.querySelector('[role="listbox"]'),o=e.querySelector(':scope > input[type="hidden"]'),r=e.querySelector('header input[type="text"]');if(!(t&&a&&i&&o)){const n=[];return t||n.push("trigger"),a||n.push("popover"),i||n.push("listbox"),o||n.push("input"),void console.error(`Select component initialisation failed. Missing element(s): ${n.join(", ")}`,e)}const s=Array.from(i.querySelectorAll('[role="option"]'));let d=[...s],c=-1;const l=e=>{if(c>-1&&s[c]&&s[c].classList.remove("active"),c=e,c>-1){const e=s[c];e.classList.add("active"),e.id?t.setAttribute("aria-activedescendant",e.id):t.removeAttribute("aria-activedescendant")}else t.removeAttribute("aria-activedescendant")},u=()=>{const e=getComputedStyle(a);return parseFloat(e.transitionDuration)>0||parseFloat(e.transitionDelay)>0},v=(t,a=!0)=>{if(t&&(n.innerHTML=t.dataset.label||t.innerHTML,o.value=t.dataset.value,i.querySelector('[role="option"][aria-selected="true"]')?.removeAttribute("aria-selected"),t.setAttribute("aria-selected","true"),a)){const n=new CustomEvent("change",{detail:{value:t.dataset.value},bubbles:!0});e.dispatchEvent(n)}},p=(e=!0)=>{if("true"!==a.getAttribute("aria-hidden")){if(r){const e=()=>{r.value="",d=[...s],s.forEach((e=>e.setAttribute("aria-hidden","false")))};u()?a.addEventListener("transitionend",e,{once:!0}):e()}e&&t.focus(),a.setAttribute("aria-hidden","true"),t.setAttribute("aria-expanded","false"),l(-1)}},b=e=>{if(!e)return;const t=o.value,n=e.dataset.value;null!=n&&n!==t&&v(e),p()};if(r){const e=()=>{const e=r.value.trim().toLowerCase();l(-1),d=[],s.forEach((t=>{const n=(t.dataset.label||t.textContent).trim().toLowerCase().includes(e);t.setAttribute("aria-hidden",String(!n)),n&&d.push(t)}))};r.addEventListener("input",e)}let h=s.find((e=>e.dataset.value===o.value));h||(h=s.find((e=>void 0!==e.dataset.value))??s[0]),v(h,!1);const m=e=>{const n="false"===a.getAttribute("aria-hidden");if(!["ArrowDown","ArrowUp","Enter","Home","End","Escape"].includes(e.key))return;if(!n)return void("Enter"!==e.key&&"Escape"!==e.key&&(e.preventDefault(),t.click()));if(e.preventDefault(),"Escape"===e.key)return void p();if("Enter"===e.key)return void(c>-1&&b(s[c]));if(0===d.length)return;const i=c>-1?d.indexOf(s[c]):-1;let o=i;switch(e.key){case"ArrowDown":i0?o=i-1:-1===i&&(o=0);break;case"Home":o=0;break;case"End":o=d.length-1}if(o!==i){const e=d[o];l(s.indexOf(e)),e.scrollIntoView({block:"nearest",behavior:"smooth"})}};i.addEventListener("mousemove",(e=>{const t=e.target.closest('[role="option"]');if(t&&d.includes(t)){const e=s.indexOf(t);e!==c&&l(e)}})),i.addEventListener("mouseleave",(()=>{const e=i.querySelector('[role="option"][aria-selected="true"]');l(e?s.indexOf(e):-1)})),t.addEventListener("keydown",m),r&&r.addEventListener("keydown",m);t.addEventListener("click",(()=>{"true"===t.getAttribute("aria-expanded")?p():(()=>{document.dispatchEvent(new CustomEvent("basecoat:popover",{detail:{source:e}})),r&&(u()?a.addEventListener("transitionend",(()=>{r.focus()}),{once:!0}):r.focus()),a.setAttribute("aria-hidden","false"),t.setAttribute("aria-expanded","true");const n=i.querySelector('[role="option"][aria-selected="true"]');n&&(l(s.indexOf(n)),n.scrollIntoView({block:"nearest"}))})()})),i.addEventListener("click",(e=>{const t=e.target.closest('[role="option"]');t&&b(t)})),document.addEventListener("click",(t=>{e.contains(t.target)||p(!1)})),document.addEventListener("basecoat:popover",(t=>{t.detail.source!==e&&p(!1)})),a.setAttribute("aria-hidden","true"),e.selectByValue=e=>{const t=s.find((t=>t.dataset.value===e));b(t)},e.dataset.selectInitialized=!0,e.dispatchEvent(new CustomEvent("basecoat:initialized"))};window.basecoat&&window.basecoat.register("select","div.select:not([data-select-initialized])",e)})(),(()=>{if(!window.history.__basecoatPatched){const e=window.history.pushState;window.history.pushState=function(...t){e.apply(this,t),window.dispatchEvent(new Event("basecoat:locationchange"))};const t=window.history.replaceState;window.history.replaceState=function(...e){t.apply(this,e),window.dispatchEvent(new Event("basecoat:locationchange"))},window.history.__basecoatPatched=!0}const e=e=>{const t="false"!==e.dataset.initialOpen,n="true"===e.dataset.initialMobileOpen,a=parseInt(e.dataset.breakpoint)||768;let i=a>0?window.innerWidth>=a?t:n:t;const o=()=>{const t=window.location.pathname.replace(/\/$/,"");e.querySelectorAll("a").forEach((e=>{if(e.hasAttribute("data-ignore-current"))return;new URL(e.href).pathname.replace(/\/$/,"")===t?e.setAttribute("aria-current","page"):e.removeAttribute("aria-current")}))},r=()=>{e.setAttribute("aria-hidden",!i),i?e.removeAttribute("inert"):e.setAttribute("inert","")},s=e=>{i=e,r()},d=e.id;document.addEventListener("basecoat:sidebar",(e=>{if(!e.detail?.id||e.detail.id===d)switch(e.detail?.action){case"open":s(!0);break;case"close":s(!1);break;default:s(!i)}})),e.addEventListener("click",(t=>{const n=t.target,i=e.querySelector("nav");if(window.innerWidth{const e=e=>{const t=e.querySelector('[role="tablist"]');if(!t)return;const n=Array.from(t.querySelectorAll('[role="tab"]')),a=n.map((e=>document.getElementById(e.getAttribute("aria-controls")))).filter(Boolean),i=e=>{n.forEach(((e,t)=>{e.setAttribute("aria-selected","false"),e.setAttribute("tabindex","-1"),a[t]&&(a[t].hidden=!0)})),e.setAttribute("aria-selected","true"),e.setAttribute("tabindex","0");const t=document.getElementById(e.getAttribute("aria-controls"));t&&(t.hidden=!1)};t.addEventListener("click",(e=>{const t=e.target.closest('[role="tab"]');t&&i(t)})),t.addEventListener("keydown",(e=>{const t=e.target;if(!n.includes(t))return;let a;const o=n.indexOf(t);switch(e.key){case"ArrowRight":a=n[(o+1)%n.length];break;case"ArrowLeft":a=n[(o-1+n.length)%n.length];break;case"Home":a=n[0];break;case"End":a=n[n.length-1];break;default:return}e.preventDefault(),i(a),a.focus()})),e.dataset.tabsInitialized=!0,e.dispatchEvent(new CustomEvent("basecoat:initialized"))};window.basecoat&&window.basecoat.register("tabs",".tabs:not([data-tabs-initialized])",e)})(),(()=>{let e;const t=new WeakMap;let n=!1;const a={success:'',error:'',info:'',warning:''};function i(e){if(e.dataset.toastInitialized)return;const a=parseInt(e.dataset.duration),i=-1!==a?a||("error"===e.dataset.category?5e3:3e3):-1,o={remainingTime:i,timeoutId:null,startTime:null};-1!==i&&(n?o.timeoutId=null:(o.startTime=Date.now(),o.timeoutId=setTimeout((()=>s(e)),i))),t.set(e,o),e.dataset.toastInitialized="true"}function o(){n||(n=!0,e.querySelectorAll('.toast:not([aria-hidden="true"])').forEach((e=>{if(!t.has(e))return;const n=t.get(e);n.timeoutId&&(clearTimeout(n.timeoutId),n.timeoutId=null,n.remainingTime-=Date.now()-n.startTime)})))}function r(){n&&(n=!1,e.querySelectorAll('.toast:not([aria-hidden="true"])').forEach((e=>{if(!t.has(e))return;const n=t.get(e);-1===n.remainingTime||n.timeoutId||(n.remainingTime>0?(n.startTime=Date.now(),n.timeoutId=setTimeout((()=>s(e)),n.remainingTime)):s(e))})))}function s(e){if(!t.has(e))return;const n=t.get(e);clearTimeout(n.timeoutId),t.delete(e),e.contains(document.activeElement)&&document.activeElement.blur(),e.setAttribute("aria-hidden","true"),e.addEventListener("transitionend",(()=>e.remove()),{once:!0})}document.addEventListener("basecoat:toast",(t=>{if(!e)return void console.error("Cannot create toast: toaster container not found on page.");const n=function(e){const{category:t="info",title:n,description:i,action:o,cancel:r,duration:s,icon:d}=e,c=d||t&&a[t]||"",l=n?`

${n}

`:"",u=i?`

${i}

`:"",v=o?.href?`${o.label}`:o?.onclick?``:"",p=r?``:"",b=`\n \n
\n ${c}\n
\n ${l}\n ${u}\n
\n ${v||p?`
${v}${p}
`:""}\n
\n \n \n `,h=document.createElement("template");return h.innerHTML=b.trim(),h.content.firstChild}(t.detail?.config||{});e.appendChild(n)})),window.basecoat&&(window.basecoat.register("toaster","#toaster:not([data-toaster-initialized])",(function(t){t.dataset.toasterInitialized||(e=t,e.addEventListener("mouseenter",o),e.addEventListener("mouseleave",r),e.addEventListener("click",(e=>{const t=e.target.closest(".toast footer a"),n=e.target.closest(".toast footer button");(t||n)&&s(e.target.closest(".toast"))})),e.querySelectorAll(".toast:not([data-toast-initialized])").forEach(i),e.dataset.toasterInitialized="true",e.dispatchEvent(new CustomEvent("basecoat:initialized")))})),window.basecoat.register("toast",".toast:not([data-toast-initialized])",i))})(); \ No newline at end of file +(()=>{const e={};let t=null;const n=()=>{Object.entries(e).forEach((([e,{selector:t,init:n}])=>{document.querySelectorAll(t).forEach(n)}))},i=t=>{t.nodeType===Node.ELEMENT_NODE&&Object.entries(e).forEach((([e,{selector:n,init:i}])=>{t.matches(n)&&i(t),t.querySelectorAll(n).forEach(i)}))},a=()=>{t||(t=new MutationObserver((e=>{e.forEach((e=>{e.addedNodes.forEach(i)}))})),t.observe(document.body,{childList:!0,subtree:!0}))};window.basecoat={register:(t,n,i)=>{e[t]={selector:n,init:i}},init:t=>{const n=e[t];if(!n)return void console.warn(`Component '${t}' not found in registry`);const i=`data-${t}-initialized`;document.querySelectorAll(`[${i}]`).forEach((e=>{e.removeAttribute(i)})),document.querySelectorAll(n.selector).forEach(n.init)},initAll:()=>{Object.entries(e).forEach((([e,{selector:t}])=>{const n=`data-${e}-initialized`;document.querySelectorAll(`[${n}]`).forEach((e=>{e.removeAttribute(n)}))})),n()},start:a,stop:()=>{t&&(t.disconnect(),t=null)}},document.addEventListener("DOMContentLoaded",(()=>{n(),a()}))})(),(()=>{const e=e=>{const t=e.querySelector("header input"),n=e.querySelector('[role="menu"]');if(!t||!n){const i=[];return t||i.push("input"),n||i.push("menu"),void console.error(`Command component initialization failed. Missing element(s): ${i.join(", ")}`,e)}const i=Array.from(n.querySelectorAll('[role="menuitem"]')),a=i.filter((e=>!e.hasAttribute("disabled")&&"true"!==e.getAttribute("aria-disabled")));let r=[...a],o=-1;const s=e=>{if(o>-1&&a[o]&&a[o].classList.remove("active"),o=e,o>-1){const e=a[o];e.classList.add("active"),e.id?t.setAttribute("aria-activedescendant",e.id):t.removeAttribute("aria-activedescendant")}else t.removeAttribute("aria-activedescendant")};t.addEventListener("input",(()=>{const e=t.value.trim().toLowerCase();s(-1),r=[],i.forEach((t=>{if(t.hasAttribute("data-force"))return t.setAttribute("aria-hidden","false"),void(a.includes(t)&&r.push(t));const n=(t.dataset.filter||t.textContent).trim().toLowerCase(),i=(t.dataset.keywords||"").toLowerCase().split(/[\s,]+/).filter(Boolean).some((t=>t.includes(e))),o=n.includes(e)||i;t.setAttribute("aria-hidden",String(!o)),o&&a.includes(t)&&r.push(t)})),r.length>0&&(s(a.indexOf(r[0])),r[0].scrollIntoView({block:"nearest"}))}));n.addEventListener("mousemove",(e=>{const t=e.target.closest('[role="menuitem"]');if(t&&r.includes(t)){const e=a.indexOf(t);e!==o&&s(e)}})),n.addEventListener("click",(t=>{const n=t.target.closest('[role="menuitem"]');if(n&&r.includes(n)){const t=e.closest("dialog.command-dialog");t&&!n.hasAttribute("data-keep-command-open")&&t.close()}})),t.addEventListener("keydown",(e=>{if(!["ArrowDown","ArrowUp","Enter","Home","End"].includes(e.key))return;if("Enter"===e.key)return e.preventDefault(),void(o>-1&&a[o]?.click());if(0===r.length)return;e.preventDefault();const t=o>-1?r.indexOf(a[o]):-1;let n=t;switch(e.key){case"ArrowDown":t0?n=t-1:-1===t&&(n=0);break;case"Home":n=0;break;case"End":n=r.length-1}if(n!==t){const e=r[n];s(a.indexOf(e)),e.scrollIntoView({block:"nearest",behavior:"smooth"})}})),r.length>0&&(s(a.indexOf(r[0])),r[0].scrollIntoView({block:"nearest"})),e.dataset.commandInitialized=!0,e.dispatchEvent(new CustomEvent("basecoat:initialized"))};window.basecoat&&window.basecoat.register("command",".command:not([data-command-initialized])",e)})(),(()=>{const e=e=>{const t=e.querySelector(":scope > button"),n=e.querySelector(":scope > [data-popover]"),i=n.querySelector('[role="menu"]');if(!t||!i||!n){const a=[];return t||a.push("trigger"),i||a.push("menu"),n||a.push("popover"),void console.error(`Dropdown menu initialisation failed. Missing element(s): ${a.join(", ")}`,e)}let a=[],r=-1;const o=(e=!0)=>{"false"!==t.getAttribute("aria-expanded")&&(t.setAttribute("aria-expanded","false"),t.removeAttribute("aria-activedescendant"),n.setAttribute("aria-hidden","true"),e&&t.focus(),d(-1))},s=(r=!1)=>{document.dispatchEvent(new CustomEvent("basecoat:popover",{detail:{source:e}})),t.setAttribute("aria-expanded","true"),n.setAttribute("aria-hidden","false"),a=Array.from(i.querySelectorAll('[role^="menuitem"]')).filter((e=>!e.hasAttribute("disabled")&&"true"!==e.getAttribute("aria-disabled"))),a.length>0&&r&&("first"===r?d(0):"last"===r&&d(a.length-1))},d=e=>{if(r>-1&&a[r]&&a[r].classList.remove("active"),r=e,r>-1&&a[r]){const e=a[r];e.classList.add("active"),t.setAttribute("aria-activedescendant",e.id)}else t.removeAttribute("aria-activedescendant")};t.addEventListener("click",(()=>{"true"===t.getAttribute("aria-expanded")?o():s(!1)})),e.addEventListener("keydown",(e=>{const n="true"===t.getAttribute("aria-expanded");if("Escape"===e.key)return void(n&&o());if(!n)return void(["Enter"," "].includes(e.key)?(e.preventDefault(),s(!1)):"ArrowDown"===e.key?(e.preventDefault(),s("first")):"ArrowUp"===e.key&&(e.preventDefault(),s("last")));if(0===a.length)return;let i=r;switch(e.key){case"ArrowDown":e.preventDefault(),i=-1===r?0:Math.min(r+1,a.length-1);break;case"ArrowUp":e.preventDefault(),i=-1===r?a.length-1:Math.max(r-1,0);break;case"Home":e.preventDefault(),i=0;break;case"End":e.preventDefault(),i=a.length-1;break;case"Enter":case" ":return e.preventDefault(),a[r]?.click(),void o()}i!==r&&d(i)})),i.addEventListener("mousemove",(e=>{const t=e.target.closest('[role^="menuitem"]');if(t&&a.includes(t)){const e=a.indexOf(t);e!==r&&d(e)}})),i.addEventListener("mouseleave",(()=>{d(-1)})),i.addEventListener("click",(e=>{e.target.closest('[role^="menuitem"]')&&o()})),document.addEventListener("click",(t=>{e.contains(t.target)||o()})),document.addEventListener("basecoat:popover",(t=>{t.detail.source!==e&&o(!1)})),e.dataset.dropdownMenuInitialized=!0,e.dispatchEvent(new CustomEvent("basecoat:initialized"))};window.basecoat&&window.basecoat.register("dropdown-menu",".dropdown-menu:not([data-dropdown-menu-initialized])",e)})(),(()=>{const e=e=>{const t=e.querySelector(":scope > button"),n=e.querySelector(":scope > [data-popover]");if(!t||!n){const i=[];return t||i.push("trigger"),n||i.push("content"),void console.error(`Popover initialisation failed. Missing element(s): ${i.join(", ")}`,e)}const i=(e=!0)=>{"false"!==t.getAttribute("aria-expanded")&&(t.setAttribute("aria-expanded","false"),n.setAttribute("aria-hidden","true"),e&&t.focus())};t.addEventListener("click",(()=>{"true"===t.getAttribute("aria-expanded")?i():(()=>{document.dispatchEvent(new CustomEvent("basecoat:popover",{detail:{source:e}}));const i=n.querySelector("[autofocus]");i&&n.addEventListener("transitionend",(()=>{i.focus()}),{once:!0}),t.setAttribute("aria-expanded","true"),n.setAttribute("aria-hidden","false")})()})),e.addEventListener("keydown",(e=>{"Escape"===e.key&&i()})),document.addEventListener("click",(t=>{e.contains(t.target)||i()})),document.addEventListener("basecoat:popover",(t=>{t.detail.source!==e&&i(!1)})),e.dataset.popoverInitialized=!0,e.dispatchEvent(new CustomEvent("basecoat:initialized"))};window.basecoat&&window.basecoat.register("popover",".popover:not([data-popover-initialized])",e)})(),(()=>{const e=e=>{const t=e.querySelector(":scope > button"),n=t.querySelector(":scope > span"),i=e.querySelector(":scope > [data-popover]"),a=i?i.querySelector('[role="listbox"]'):null,r=e.querySelector(':scope > input[type="hidden"]'),o=e.querySelector('header input[type="text"]');if(!(t&&i&&a&&r)){const n=[];return t||n.push("trigger"),i||n.push("popover"),a||n.push("listbox"),r||n.push("input"),void console.error(`Select component initialisation failed. Missing element(s): ${n.join(", ")}`,e)}const s=Array.from(a.querySelectorAll('[role="option"]')),d=s.filter((e=>"true"!==e.getAttribute("aria-disabled")));let c=[...d],l=-1;const u="true"===a.getAttribute("aria-multiselectable"),p=u?new Set:null,v=u?e.dataset.placeholder||"":null,h=e=>e.dataset.value??e.textContent.trim(),f=e=>{if(l>-1&&d[l]&&d[l].classList.remove("active"),l=e,l>-1){const e=d[l];e.classList.add("active"),e.id?t.setAttribute("aria-activedescendant",e.id):t.removeAttribute("aria-activedescendant")}else t.removeAttribute("aria-activedescendant")},m=()=>{const e=getComputedStyle(i);return parseFloat(e.transitionDuration)>0||parseFloat(e.transitionDelay)>0},b=(t,i=!0)=>{let a;if(u){const e=Array.isArray(t)?t:[];p.clear(),e.forEach((e=>p.add(e)));const i=d.filter((e=>p.has(e)));0===i.length?(n.textContent=v,n.classList.add("text-muted-foreground")):(n.textContent=i.map((e=>e.dataset.label||e.textContent.trim())).join(", "),n.classList.remove("text-muted-foreground")),a=i.map(h),r.value=JSON.stringify(a)}else{const e=t;if(!e)return;n.innerHTML=e.innerHTML,a=h(e),r.value=a}d.forEach((e=>{(u?p.has(e):e===t)?e.setAttribute("aria-selected","true"):e.removeAttribute("aria-selected")})),i&&e.dispatchEvent(new CustomEvent("change",{detail:{value:a},bubbles:!0}))},w=(e=!0)=>{if("true"!==i.getAttribute("aria-hidden")){if(o){const e=()=>{o.value="",c=[...d],s.forEach((e=>e.setAttribute("aria-hidden","false")))};m()?i.addEventListener("transitionend",e,{once:!0}):e()}e&&t.focus(),i.setAttribute("aria-hidden","true"),t.setAttribute("aria-expanded","false"),f(-1)}},g=e=>{p.has(e)?p.delete(e):p.add(e),b(d.filter((e=>p.has(e))))},E=e=>{if(u){const t=d.find((t=>h(t)===e&&!p.has(t)));if(!t)return;p.add(t),b(d.filter((e=>p.has(e))))}else{const t=d.find((t=>h(t)===e));if(!t)return;r.value!==e&&b(t),w()}},A=e=>{if(!u)return;const t=d.find((t=>h(t)===e&&p.has(t)));t&&(p.delete(t),b(d.filter((e=>p.has(e)))))},y=e=>{if(!u)return;const t=d.find((t=>h(t)===e));t&&g(t)};if(o){const e=()=>{const e=o.value.trim().toLowerCase();f(-1),c=[],s.forEach((t=>{if(t.hasAttribute("data-force"))return t.setAttribute("aria-hidden","false"),void(d.includes(t)&&c.push(t));const n=(t.dataset.filter||t.textContent).trim().toLowerCase(),i=(t.dataset.keywords||"").toLowerCase().split(/[\s,]+/).filter(Boolean).some((t=>t.includes(e))),a=n.includes(e)||i;t.setAttribute("aria-hidden",String(!a)),a&&d.includes(t)&&c.push(t)}))};o.addEventListener("input",e)}if(u){const e=d.filter((e=>"true"===e.getAttribute("aria-selected")));try{const t=JSON.parse(r.value||"[]"),n=new Set(d.map(h)),i=Array.isArray(t)?t.filter((e=>n.has(e))):[],a=[];i.length>0?i.forEach((e=>{const t=d.find((t=>h(t)===e&&!a.includes(t)));t&&a.push(t)})):a.push(...e),b(a,!1)}catch(t){b(e,!1)}}else{const e=d.find((e=>h(e)===r.value))||d[0];e&&b(e,!1)}const k=e=>{const n="false"===i.getAttribute("aria-hidden");if(!["ArrowDown","ArrowUp","Enter","Home","End","Escape"].includes(e.key))return;if(!n)return void("Enter"!==e.key&&"Escape"!==e.key&&(e.preventDefault(),t.click()));if(e.preventDefault(),"Escape"===e.key)return void w();if("Enter"===e.key){if(l>-1){const e=d[l];u?g(e):(r.value!==h(e)&&b(e),w())}return}if(0===c.length)return;const a=l>-1?c.indexOf(d[l]):-1;let o=a;switch(e.key){case"ArrowDown":a0?o=a-1:-1===a&&(o=0);break;case"Home":o=0;break;case"End":o=c.length-1}if(o!==a){const e=c[o];f(d.indexOf(e)),e.scrollIntoView({block:"nearest",behavior:"smooth"})}};a.addEventListener("mousemove",(e=>{const t=e.target.closest('[role="option"]');if(t&&c.includes(t)){const e=d.indexOf(t);e!==l&&f(e)}})),a.addEventListener("mouseleave",(()=>{const e=a.querySelector('[role="option"][aria-selected="true"]');f(e?d.indexOf(e):-1)})),t.addEventListener("keydown",k),o&&o.addEventListener("keydown",k);t.addEventListener("click",(()=>{"true"===t.getAttribute("aria-expanded")?w():(()=>{document.dispatchEvent(new CustomEvent("basecoat:popover",{detail:{source:e}})),o&&(m()?i.addEventListener("transitionend",(()=>{o.focus()}),{once:!0}):o.focus()),i.setAttribute("aria-hidden","false"),t.setAttribute("aria-expanded","true");const n=a.querySelector('[role="option"][aria-selected="true"]');n&&(f(d.indexOf(n)),n.scrollIntoView({block:"nearest"}))})()})),a.addEventListener("click",(e=>{const n=e.target.closest('[role="option"]');if(!n)return;const i=d.find((e=>e===n));i&&(u?(g(i),f(d.indexOf(i)),o?o.focus():t.focus()):(r.value!==h(i)&&b(i),w()))})),document.addEventListener("click",(t=>{e.contains(t.target)||w(!1)})),document.addEventListener("basecoat:popover",(t=>{t.detail.source!==e&&w(!1)})),i.setAttribute("aria-hidden","true"),Object.defineProperty(e,"value",{get:()=>u?d.filter((e=>p.has(e))).map(h):r.value,set:e=>{if(u){const t=Array.isArray(e)?e:null!=e?[e]:[],n=[];t.forEach((e=>{const t=d.find((t=>h(t)===e&&!n.includes(t)));t&&n.push(t)})),b(n)}else{const t=d.find((t=>h(t)===e));t&&(b(t),w())}}}),e.select=E,e.selectByValue=E,u&&(e.deselect=A,e.toggle=y,e.selectAll=()=>b(d),e.selectNone=()=>b([])),e.dataset.selectInitialized=!0,e.dispatchEvent(new CustomEvent("basecoat:initialized"))};window.basecoat&&window.basecoat.register("select","div.select:not([data-select-initialized])",e)})(),(()=>{if(!window.history.__basecoatPatched){const e=window.history.pushState;window.history.pushState=function(...t){e.apply(this,t),window.dispatchEvent(new Event("basecoat:locationchange"))};const t=window.history.replaceState;window.history.replaceState=function(...e){t.apply(this,e),window.dispatchEvent(new Event("basecoat:locationchange"))},window.history.__basecoatPatched=!0}const e=e=>{const t="false"!==e.dataset.initialOpen,n="true"===e.dataset.initialMobileOpen,i=parseInt(e.dataset.breakpoint)||768;let a=i>0?window.innerWidth>=i?t:n:t;const r=()=>{const t=window.location.pathname.replace(/\/$/,"");e.querySelectorAll("a").forEach((e=>{if(e.hasAttribute("data-ignore-current"))return;new URL(e.href).pathname.replace(/\/$/,"")===t?e.setAttribute("aria-current","page"):e.removeAttribute("aria-current")}))},o=()=>{e.setAttribute("aria-hidden",!a),a?e.removeAttribute("inert"):e.setAttribute("inert","")},s=e=>{a=e,o()},d=e.id;document.addEventListener("basecoat:sidebar",(e=>{if(!e.detail?.id||e.detail.id===d)switch(e.detail?.action){case"open":s(!0);break;case"close":s(!1);break;default:s(!a)}})),e.addEventListener("click",(t=>{const n=t.target,a=e.querySelector("nav");if(window.innerWidth{const e=e=>{const t=e.querySelector('[role="tablist"]');if(!t)return;const n=Array.from(t.querySelectorAll('[role="tab"]')),i=n.map((e=>document.getElementById(e.getAttribute("aria-controls")))).filter(Boolean),a=e=>{n.forEach(((e,t)=>{e.setAttribute("aria-selected","false"),e.setAttribute("tabindex","-1"),i[t]&&(i[t].hidden=!0)})),e.setAttribute("aria-selected","true"),e.setAttribute("tabindex","0");const t=document.getElementById(e.getAttribute("aria-controls"));t&&(t.hidden=!1)};t.addEventListener("click",(e=>{const t=e.target.closest('[role="tab"]');t&&a(t)})),t.addEventListener("keydown",(e=>{const t=e.target;if(!n.includes(t))return;let i;const r=n.indexOf(t);switch(e.key){case"ArrowRight":i=n[(r+1)%n.length];break;case"ArrowLeft":i=n[(r-1+n.length)%n.length];break;case"Home":i=n[0];break;case"End":i=n[n.length-1];break;default:return}e.preventDefault(),a(i),i.focus()})),e.dataset.tabsInitialized=!0,e.dispatchEvent(new CustomEvent("basecoat:initialized"))};window.basecoat&&window.basecoat.register("tabs",".tabs:not([data-tabs-initialized])",e)})(),(()=>{let e;const t=new WeakMap;let n=!1;const i={success:'',error:'',info:'',warning:''};function a(e){if(e.dataset.toastInitialized)return;const i=parseInt(e.dataset.duration),a=-1!==i?i||("error"===e.dataset.category?5e3:3e3):-1,r={remainingTime:a,timeoutId:null,startTime:null};-1!==a&&(n?r.timeoutId=null:(r.startTime=Date.now(),r.timeoutId=setTimeout((()=>s(e)),a))),t.set(e,r),e.dataset.toastInitialized="true"}function r(){n||(n=!0,e.querySelectorAll('.toast:not([aria-hidden="true"])').forEach((e=>{if(!t.has(e))return;const n=t.get(e);n.timeoutId&&(clearTimeout(n.timeoutId),n.timeoutId=null,n.remainingTime-=Date.now()-n.startTime)})))}function o(){n&&(n=!1,e.querySelectorAll('.toast:not([aria-hidden="true"])').forEach((e=>{if(!t.has(e))return;const n=t.get(e);-1===n.remainingTime||n.timeoutId||(n.remainingTime>0?(n.startTime=Date.now(),n.timeoutId=setTimeout((()=>s(e)),n.remainingTime)):s(e))})))}function s(e){if(!t.has(e))return;const n=t.get(e);clearTimeout(n.timeoutId),t.delete(e),e.contains(document.activeElement)&&document.activeElement.blur(),e.setAttribute("aria-hidden","true"),e.addEventListener("transitionend",(()=>e.remove()),{once:!0})}document.addEventListener("basecoat:toast",(t=>{if(!e)return void console.error("Cannot create toast: toaster container not found on page.");const n=function(e){const{category:t="info",title:n,description:a,action:r,cancel:o,duration:s,icon:d}=e,c=d||t&&i[t]||"",l=n?`

${n}

`:"",u=a?`

${a}

`:"",p=r?.href?`${r.label}`:r?.onclick?``:"",v=o?``:"",h=`\n \n
\n ${c}\n
\n ${l}\n ${u}\n
\n ${p||v?`
${p}${v}
`:""}\n
\n \n \n `,f=document.createElement("template");return f.innerHTML=h.trim(),f.content.firstChild}(t.detail?.config||{});e.appendChild(n)})),window.basecoat&&(window.basecoat.register("toaster","#toaster:not([data-toaster-initialized])",(function(t){t.dataset.toasterInitialized||(e=t,e.addEventListener("mouseenter",r),e.addEventListener("mouseleave",o),e.addEventListener("click",(e=>{const t=e.target.closest(".toast footer a"),n=e.target.closest(".toast footer button");(t||n)&&s(e.target.closest(".toast"))})),e.querySelectorAll(".toast:not([data-toast-initialized])").forEach(a),e.dataset.toasterInitialized="true",e.dispatchEvent(new CustomEvent("basecoat:initialized")))})),window.basecoat.register("toast",".toast:not([data-toast-initialized])",a))})(); \ No newline at end of file diff --git a/app/assets/htmx-sse.min.js b/app/assets/htmx-sse.min.js index 2ba734c..4528733 100644 --- a/app/assets/htmx-sse.min.js +++ b/app/assets/htmx-sse.min.js @@ -1 +1 @@ -(function(){var g;htmx.defineExtension("sse",{init:function(e){g=e;if(htmx.createEventSource==undefined){htmx.createEventSource=t}},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(e,t){var r=t.target||t.detail.elt;switch(e){case"htmx:beforeCleanupElement":var n=g.getInternalData(r);var s=n.sseEventSource;if(s){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"nodeReplaced"});n.sseEventSource.close()}return;case"htmx:afterProcessNode":i(r)}}});function t(e){return new EventSource(e,{withCredentials:true})}function a(n){if(g.getAttributeValue(n,"sse-swap")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var t=g.getAttributeValue(n,"sse-swap");var r=t.split(",");for(var i=0;i0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;q(t,function(e){return!!(r=o(t,ce(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function A(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function L(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function N(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){R(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=A(t);let r;if(n==="html"){r=new DocumentFragment;const i=L(e);N(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=L(t);N(r,i.body);r.title=i.title}else{const i=L('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function D(e){return typeof e==="function"}function k(e){return t(e,"Object")}function oe(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function se(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function le(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function v(e){try{return JSON.parse(e)}catch(e){R(e);return null}}function B(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function U(e){const t=new URL(e,"http://x");if(t){e=t.pathname+t.search}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return On(te().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function f(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return f(te(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(te(),e)}}function b(){return window}function z(e,t){e=w(e);if(t){b().setTimeout(function(){z(e);e=null},t)}else{u(e).removeChild(e)}}function ce(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ce(w(e));if(!e){return}if(n){b().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ce(w(e));if(!r){return}if(n){b().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=w(e);e.classList.toggle(t)}function Z(e,t){e=w(e);ie(e.parentElement.children,function(e){G(e,t)});K(ce(e),t)}function g(e,t){e=ce(w(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function pe(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=w(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=pe(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ce(t),pe(r.slice(8)))}else if(r.indexOf("find ")===0){e=f(p(t),pe(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=ce(t).nextElementSibling}else if(r.indexOf("next ")===0){e=ge(t,pe(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ce(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,pe(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=y(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=p(y(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var ge=function(t,e,n){const r=p(y(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ue(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(te().body,e)[0]}}function w(e,t){if(typeof e==="string"){return f(p(t)||document,e)}else{return e}}function ye(e,t,n,r){if(D(t)){return{target:te().body,event:J(e),listener:t,options:n}}else{return{target:w(e),event:J(t),listener:n,options:r}}}function xe(t,n,r,o){Gn(function(){const e=ye(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=D(n);return e?n:r}function be(t,n,r){Gn(function(){const e=ye(t,n,r);e.target.removeEventListener(e.event,e.listener)});return D(n)?n:r}const ve=te().createElement("output");function we(t,n){const e=ne(t,n);if(e){if(e==="this"){return[Se(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=ce(q(t,function(e){return e!==t&&s(ce(e),n)}));if(i){r.push(...we(i,n))}}if(r.length===0){R('The selector "'+e+'" on '+n+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ce(q(e,function(e){return a(ce(e),t)!=null}))}function Ee(e){const t=ne(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ue(e,t)}}else{const n=oe(e);if(n.boosted){return te().body}else{return e}}}function Ce(e){return Q.config.attributesToSettle.includes(e)}function Oe(t,n){ie(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});ie(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Jn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){ie(r,function(e){let t;const n=o.cloneNode(true);t=te().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}ie(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(te().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=f("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=f("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){ie(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=a(e,"id");const n=te().getElementById(t);if(n!=null){if(e.moveBefore){let e=f("#--htmx-preserve-pantry--");if(e==null){te().body.insertAdjacentHTML("afterend","
");e=f("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Ae(l,e,c){ie(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=p(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Le(e){return function(){G(e,Q.config.addedClass);Ft(ce(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){Ae(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ce(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Le(o))}}}function Ie(e,t){let n=0;while(n0}function $e(h,d,p,g){if(!g){g={}}let m=null;let n=null;let e=function(){re(g.beforeSwapCallback);h=w(h);const r=g.contextElement?y(g.contextElement,false):te();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=Sn(h);if(p.swapStyle==="textContent"){h.textContent=d}else{let n=P(d);o.title=g.title||n.title;if(g.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(g.selectOOB){const i=g.selectOOB.split(",");for(let t=0;t0){b().setTimeout(n,p.settleDelay)}else{n()}};let t=Q.config.globalViewTransitions;if(p.hasOwnProperty("transition")){t=p.transition}const r=g.contextElement||te();if(t&&ae(r,"htmx:beforeTransition",g.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){m=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(p?.swapDelay&&p.swapDelay>0){b().setTimeout(e,p.swapDelay)}else{e()}}catch(e){fe(r,"htmx:swapError",g.eventInfo);re(n);throw e}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(k(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=On(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(te().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=O(e,Qe).trim();e.shift()}else{t=O(e,E)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{O(o,C);const l=o.length;const c=O(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};O(o,C);u.pollInterval=d(O(o,/[,\[\s]/));O(o,C);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const f={trigger:c};var i=nt(e,o,"event");if(i){f.eventFilter=i}O(o,C);while(o.length>0&&o[0]!==","){const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(O(o,E))}else if(a==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=O(o,E);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=rt(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(O(o,E))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=O(o,E)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=rt(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=O(o,E)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,C)}r.push(f)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,C)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=a(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){oe(e).cancelled=true}function ct(e,t,n){const r=oe(e);r.timeout=b().setTimeout(function(){if(se(e)&&r.cancelled!==true){if(!pt(n,e,Bt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function at(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){gt(t,function(e,t){const n=ce(e);if(ft(n)){S(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){if(e.type==="submit"||e.type==="click"){t=ce(e.target)||t;if(t.tagName==="FORM"){return true}if(t.form&&t.type==="submit"){return true}t=t.closest("a");if(t&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return oe(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(te().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function gt(l,c,e,u,f){const a=oe(l);let t;if(u.from){t=m(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in a)){a.lastValue=new WeakMap}t.forEach(function(e){if(!a.lastValue.has(u)){a.lastValue.set(u,new WeakMap)}a.lastValue.get(u).set(e,e.value)})}ie(t,function(i){const s=function(e){if(!se(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(f||ht(e,l)){e.preventDefault()}if(pt(u,l,e)){return}const t=oe(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ce(e.target),u.target)){return}}if(u.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(u.changed){const n=e.target;const r=n.value;const o=a.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(u.throttle>0){if(!a.throttle){ae(l,"htmx:trigger");c(l,e);a.throttle=b().setTimeout(function(){a.throttle=null},u.throttle)}}else if(u.delay>0){a.delayed=b().setTimeout(function(){ae(l,"htmx:trigger");c(l,e)},u.delay)}else{ae(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let yt=null;function xt(){if(!yt){yt=function(){mt=true};window.addEventListener("scroll",yt);window.addEventListener("resize",yt);setInterval(function(){if(mt){mt=false;ie(te().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&F(e)){e.setAttribute("data-hx-revealed","true");const t=oe(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){b().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;ie(de,function(r){if(s(t,"hx-"+r)){const o=a(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ce(e);if(ft(n)){S(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){xt();gt(r,n,t,e);bt(ce(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ue(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ce(r),n,e)}else{gt(r,n,t,e)}}function Et(e){const t=ce(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=At(e.target);const n=Nt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Nt(e);if(t){t.lastButtonClicked=null}}function At(e){return g(ce(e),"button, input[type='submit']")}function Lt(e){return e.form||g(e,"form")}function Nt(e){const t=At(e.target);if(!t){return}const n=Lt(t);return oe(n)}function It(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Pt(t,e,n){const r=oe(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){On(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Dt(t){De(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Jt(t){if(!B()){return null}t=U(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(te().body,"htmx:historyCacheMissLoad",r);$e(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});_t(r.path);ae(te().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{fe(te().body,"htmx:historyCacheMissLoadError",r)}};if(ae(te().body,"htmx:historyCacheMiss",r)){t.send()}}function en(e){Gt();e=e||location.pathname+location.search;const t=Jt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:zt(),swapSpec:n};if(ae(te().body,"htmx:historyCacheHit",r)){$e(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});_t(r.path);ae(te().body,"htmx:historyRestore",r)}}else{if(Q.config.refreshOnHistoryMiss){Q.location.reload(true)}else{Qt(e)}}}function tn(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function nn(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function rn(e,t){ie(e.concat(t),function(e){const t=oe(e);t.requestCount=(t.requestCount||1)-1});ie(e,function(e){const t=oe(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});ie(t,function(e){const t=oe(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function on(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);ie(e,e=>r.append(t,e))}}function un(e){if(e instanceof HTMLSelectElement&&e.multiple){return M(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return M(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||on(t,e)){return}else{t.push(e)}if(sn(e)){const i=ee(e,"name");ln(i,un(e),n);if(o){an(e,r)}}if(e instanceof HTMLFormElement){ie(e.elements,function(e){if(t.indexOf(e)>=0){cn(e.name,un(e),n)}else{t.push(e)}if(o){an(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}ln(t,e,n)})}}function an(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function hn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function dn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=oe(e);if(s.lastButtonClicked&&!se(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||a(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,Lt(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const f=ee(u,"name");ln(f,u.value,o)}const c=we(e,"hx-include");ie(c,function(e){fn(n,r,i,ce(e),l);if(!h(e,"form")){ie(p(e).querySelectorAll(ot),function(e){fn(n,r,i,e,l)})}});hn(r,o);return{errors:i,formData:r,values:kn(r)}}function pn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function gn(e){e=Pn(e);let n="";e.forEach(function(e,t){n=pn(n,t,e)});return n}function mn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":a(t,"id"),"HX-Current-URL":location.href};Cn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(oe(e).boosted){r["HX-Boosted"]="true"}return r}function yn(n,e){const t=ne(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){ie(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;ie(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function xn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function bn(e,t){const n=t||ne(e,"hx-swap");const r={swapStyle:oe(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&oe(e).boosted&&!xn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.slice(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{R("Unknown modifier in hx-swap: "+l)}}}}return r}function vn(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function wn(t,n,r){let o=null;jt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(vn(n)){return hn(new FormData,Pn(r))}else{return gn(r)}}}function Sn(e){return{tasks:[],elts:[e]}}function En(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ce(ue(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){b().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ce(ue(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Cn(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=a(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=On(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const c in n){if(n.hasOwnProperty(c)){if(i[c]==null){i[c]=n[c]}}}}return Cn(ce(u(r)),e,o,i,s)}function On(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Rn(e,t,n){return Cn(e,"hx-vars",true,n,t)}function Hn(e,t,n){return Cn(e,"hx-vals",false,n,t)}function Tn(e,t){return le(Rn(e,t),Hn(e,t))}function qn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function An(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function H(e,t){return t.test(e.getAllResponseHeaders())}function Ln(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:w(r)||ve,returnPromise:true})}else{let e=w(r.target);if(r.target&&!e||r.source&&!e&&!w(r.source)){e=ve}return he(t,n,w(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(t,n,null,null,{returnPromise:true})}}function Nn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function In(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Q.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",le({url:r,sameHost:i},n))}function Pn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Dn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function kn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Dn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=te().body}const M=i.handler||jn;const F=i.select||null;if(!se(r)){re(s);return e}const c=i.targetOverride||ce(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:ne(r,"hx-target")});re(l);return e}let u=oe(r);const f=u.lastButtonClicked;if(f){const A=ee(f,"formaction");if(A!=null){n=A}const L=ee(f,"formmethod");if(L!=null){if(de.includes(L.toLowerCase())){t=L}else{re(s);return e}}}const a=ne(r,"hx-confirm");if(k===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(ae(r,"htmx:confirm",G)===false){re(s);return e}}let h=r;let d=ne(r,"hx-sync");let p=null;let X=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ce(ue(r,I))}d=(N[1]||"drop").trim();u=oe(h);if(d==="drop"&&u.xhr&&u.abortable!==true){re(s);return e}else if(d==="abort"){if(u.xhr){re(s);return e}else{X=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const P=oe(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){p=P.triggerSpec.queue}}if(p==null){p="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(p==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="all"){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){he(t,n,r,o,i)})}re(s);return e}}const g=new XMLHttpRequest;u.xhr=g;u.abortable=X;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=ne(r,"hx-prompt");if(B){var y=prompt(B);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:c})){re(s);m();return e}}if(a&&!k){if(!confirm(a)){re(s);m();return e}}let x=mn(r,c,y);if(t!=="get"&&!vn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=le(x,i.headers)}const U=dn(r,t);let b=U.errors;const j=U.formData;if(i.values){hn(j,Pn(i.values))}const V=Pn(Tn(r,o));const v=hn(j,V);let w=yn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=location.href}const S=Cn(r,"hx-request");const _=oe(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:kn(w),unfilteredFormData:v,unfilteredParameters:kn(v),headers:x,elt:r,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){re(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Pn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);re(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=gn(w);if(O){R+="#"+O}}}if(!In(r,R,C)){fe(r,"htmx:invalidPath",C);re(l);m();return e}g.open(t.toUpperCase(),R,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const D in x){if(x.hasOwnProperty(D)){const Y=x[D];qn(g,D,Y)}}}const H={xhr:g,target:c,requestConfig:C,etc:i,boosted:_,select:F,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};g.onload=function(){try{const t=Nn(r);H.pathInfo.responsePath=An(g);M(r,H);if(H.keepIndicators!==true){rn(T,q)}ae(r,"htmx:afterRequest",H);ae(r,"htmx:afterOnLoad",H);if(!se(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(se(n)){e=n}}if(e){ae(e,"htmx:afterRequest",H);ae(e,"htmx:afterOnLoad",H)}}re(s)}catch(e){fe(r,"htmx:onLoadError",le({error:e},H));throw e}finally{m()}};g.onerror=function(){rn(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);re(l);m()};g.onabort=function(){rn(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);re(l);m()};g.ontimeout=function(){rn(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);re(l);m()};if(!ae(r,"htmx:beforeRequest",H)){re(s);m();return e}var T=tn(r);var q=nn(r);ie(["loadstart","loadend","progress","abort"],function(t){ie([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",H);const J=E?null:wn(g,r,w);g.send(J);return e}function Mn(e,t){const n=t.xhr;let r=null;let o=null;if(H(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(H(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(H(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=ne(e,"hx-push-url");const c=ne(e,"hx-replace-url");const u=oe(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(c){f="replace";a=c}else if(u){f="push";a=s||i}if(a){if(a==="false"){return{}}if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Fn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Xn(e){for(var t=0;t ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Ft(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};b().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.8"};Q.onLoad=V;Q.process=Ft;Q.on=xe;Q.off=be;Q.trigger=ae;Q.ajax=Ln;Q.find=f;Q.findAll=x;Q.closest=g;Q.remove=_;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=ze;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=j;Q.logNone=$;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:X,findThisElement:Se,filterValues:yn,swap:ze,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:q,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Ee,makeFragment:D,mergeObjects:le,makeSettleInfo:Sn,oobSwap:Te,querySelectorExt:ue,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function y(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function q(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;q(t,function(e){return!!(r=o(t,ce(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function A(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function L(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function N(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function D(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=A(t);let r;if(n==="html"){r=new DocumentFragment;const i=L(e);N(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=L(t);N(r,i.body);r.title=i.title}else{const i=L('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function P(e){return typeof e==="function"}function k(e){return t(e,"Object")}function oe(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function se(e){return e.getRootNode({composed:true})===document}function B(e){return e.trim().split(/\s+/)}function le(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function v(e){try{return JSON.parse(e)}catch(e){H(e);return null}}function X(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function U(e){const t=new URL(e,"http://x");if(t){e=t.pathname+t.search}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return On(te().body,function(){return eval(e)})}function V(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function j(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function $(){Q.logger=null}function f(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return f(te(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(te(),e)}}function b(){return window}function _(e,t){e=w(e);if(t){b().setTimeout(function(){_(e);e=null},t)}else{u(e).removeChild(e)}}function ce(e){return e instanceof Element?e:null}function z(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ce(w(e));if(!e){return}if(n){b().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ce(w(e));if(!r){return}if(n){b().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=w(e);e.classList.toggle(t)}function Z(e,t){e=w(e);ie(e.parentElement.children,function(e){G(e,t)});K(ce(e),t)}function g(e,t){e=ce(w(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function pe(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=w(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=pe(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ce(t),pe(r.slice(8)))}else if(r.indexOf("find ")===0){e=f(p(t),pe(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=ce(t).nextElementSibling}else if(r.indexOf("next ")===0){e=ge(t,pe(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ce(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,pe(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=y(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=p(y(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var ge=function(t,e,n){const r=p(y(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ue(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(te().body,e)[0]}}function w(e,t){if(typeof e==="string"){return f(p(t)||document,e)}else{return e}}function ye(e,t,n,r){if(P(t)){return{target:te().body,event:J(e),listener:t,options:n}}else{return{target:w(e),event:J(t),listener:n,options:r}}}function xe(t,n,r,o){Gn(function(){const e=ye(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=P(n);return e?n:r}function be(t,n,r){Gn(function(){const e=ye(t,n,r);e.target.removeEventListener(e.event,e.listener)});return P(n)?n:r}const ve=te().createElement("output");function we(t,n){const e=ne(t,n);if(e){if(e==="this"){return[Se(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=ce(q(t,function(e){return e!==t&&s(ce(e),n)}));if(i){r.push(...we(i,n))}}if(r.length===0){H('The selector "'+e+'" on '+n+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ce(q(e,function(e){return a(ce(e),t)!=null}))}function Ee(e){const t=ne(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ue(e,t)}}else{const n=oe(e);if(n.boosted){return te().body}else{return e}}}function Ce(e){return Q.config.attributesToSettle.includes(e)}function Oe(t,n){ie(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});ie(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function He(t,e){const n=Jn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){ie(r,function(e){let t;const n=o.cloneNode(true);t=te().createDocumentFragment();t.appendChild(n);if(!He(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);$e(s,e,e,t,i);Re()}ie(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(te().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Re(){const e=f("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=f("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){ie(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=a(e,"id");const n=te().getElementById(t);if(n!=null){if(e.moveBefore){let e=f("#--htmx-preserve-pantry--");if(e==null){te().body.insertAdjacentHTML("afterend","
");e=f("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Ae(l,e,c){ie(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=p(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Le(e){return function(){G(e,Q.config.addedClass);Ft(ce(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=z(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){Ae(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ce(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Le(o))}}}function Ie(e,t){let n=0;while(n0}function ze(h,d,p,g){if(!g){g={}}let m=null;let n=null;let e=function(){re(g.beforeSwapCallback);h=w(h);const r=g.contextElement?y(g.contextElement,false):te();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=Sn(h);if(p.swapStyle==="textContent"){h.textContent=d}else{let n=D(d);o.title=g.title||n.title;if(g.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(g.selectOOB){const i=g.selectOOB.split(",");for(let t=0;t0){b().setTimeout(n,p.settleDelay)}else{n()}};let t=Q.config.globalViewTransitions;if(p.hasOwnProperty("transition")){t=p.transition}const r=g.contextElement||te();if(t&&ae(r,"htmx:beforeTransition",g.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){m=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(p?.swapDelay&&p.swapDelay>0){b().setTimeout(e,p.swapDelay)}else{e()}}catch(e){fe(r,"htmx:swapError",g.eventInfo);re(n);throw e}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(k(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=On(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(te().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=O(e,Qe).trim();e.shift()}else{t=O(e,E)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{O(o,C);const l=o.length;const c=O(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};O(o,C);u.pollInterval=d(O(o,/[,\[\s]/));O(o,C);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const f={trigger:c};var i=nt(e,o,"event");if(i){f.eventFilter=i}O(o,C);while(o.length>0&&o[0]!==","){const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(O(o,E))}else if(a==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=O(o,E);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=rt(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(O(o,E))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=O(o,E)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=rt(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=O(o,E)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,C)}r.push(f)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,C)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=a(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){oe(e).cancelled=true}function ct(e,t,n){const r=oe(e);r.timeout=b().setTimeout(function(){if(se(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function at(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){gt(t,function(e,t){const n=ce(e);if(ft(n)){S(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function dt(e,t){return oe(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(te().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function gt(l,c,e,u,f){const a=oe(l);let t;if(u.from){t=m(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in a)){a.lastValue=new WeakMap}t.forEach(function(e){if(!a.lastValue.has(u)){a.lastValue.set(u,new WeakMap)}a.lastValue.get(u).set(e,e.value)})}ie(t,function(i){const s=function(e){if(!se(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(f||ht(e,i)){e.preventDefault()}if(pt(u,l,e)){return}const t=oe(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ce(e.target),u.target)){return}}if(u.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(u.changed){const n=e.target;const r=n.value;const o=a.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(u.throttle>0){if(!a.throttle){ae(l,"htmx:trigger");c(l,e);a.throttle=b().setTimeout(function(){a.throttle=null},u.throttle)}}else if(u.delay>0){a.delayed=b().setTimeout(function(){ae(l,"htmx:trigger");c(l,e)},u.delay)}else{ae(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let yt=null;function xt(){if(!yt){yt=function(){mt=true};window.addEventListener("scroll",yt);window.addEventListener("resize",yt);setInterval(function(){if(mt){mt=false;ie(te().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&F(e)){e.setAttribute("data-hx-revealed","true");const t=oe(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){b().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;ie(de,function(r){if(s(t,"hx-"+r)){const o=a(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ce(e);if(ft(n)){S(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){xt();gt(r,n,t,e);bt(ce(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ue(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ce(r),n,e)}else{gt(r,n,t,e)}}function Et(e){const t=ce(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Rt(e){const t=At(e.target);const n=Nt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Nt(e);if(t){t.lastButtonClicked=null}}function At(e){return g(ce(e),"button, input[type='submit']")}function Lt(e){return e.form||g(e,"form")}function Nt(e){const t=At(e.target);if(!t){return}const n=Lt(t);if(!n){return}return oe(n)}function It(e){e.addEventListener("click",Rt);e.addEventListener("focusin",Rt);e.addEventListener("focusout",qt)}function Dt(t,e,n){const r=oe(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){On(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){Pe(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Jt(t){if(!X()){return null}t=U(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(te().body,"htmx:historyCacheMissLoad",r);ze(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});$t(r.path);ae(te().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{fe(te().body,"htmx:historyCacheMissLoadError",r)}};if(ae(te().body,"htmx:historyCacheMiss",r)){t.send()}}function en(e){Gt();e=e||location.pathname+location.search;const t=Jt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:_t(),swapSpec:n};if(ae(te().body,"htmx:historyCacheHit",r)){ze(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});$t(r.path);ae(te().body,"htmx:historyRestore",r)}}else{if(Q.config.refreshOnHistoryMiss){Q.location.reload(true)}else{Qt(e)}}}function tn(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function nn(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function rn(e,t){ie(e.concat(t),function(e){const t=oe(e);t.requestCount=(t.requestCount||1)-1});ie(e,function(e){const t=oe(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});ie(t,function(e){const t=oe(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function on(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);ie(e,e=>r.append(t,e))}}function un(e){if(e instanceof HTMLSelectElement&&e.multiple){return M(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return M(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||on(t,e)){return}else{t.push(e)}if(sn(e)){const i=ee(e,"name");ln(i,un(e),n);if(o){an(e,r)}}if(e instanceof HTMLFormElement){ie(e.elements,function(e){if(t.indexOf(e)>=0){cn(e.name,un(e),n)}else{t.push(e)}if(o){an(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}ln(t,e,n)})}}function an(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Q.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function hn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function dn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=oe(e);if(s.lastButtonClicked&&!se(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||a(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,Lt(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const f=ee(u,"name");ln(f,u.value,o)}const c=we(e,"hx-include");ie(c,function(e){fn(n,r,i,ce(e),l);if(!h(e,"form")){ie(p(e).querySelectorAll(ot),function(e){fn(n,r,i,e,l)})}});hn(r,o);return{errors:i,formData:r,values:kn(r)}}function pn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function gn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=pn(n,t,e)});return n}function mn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":a(t,"id"),"HX-Current-URL":location.href};Cn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(oe(e).boosted){r["HX-Boosted"]="true"}return r}function yn(n,e){const t=ne(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){ie(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;ie(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function xn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function bn(e,t){const n=t||ne(e,"hx-swap");const r={swapStyle:oe(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&oe(e).boosted&&!xn(e)){r.show="top"}if(n){const s=B(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.slice(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{H("Unknown modifier in hx-swap: "+l)}}}}return r}function vn(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function wn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(vn(n)){return hn(new FormData,Dn(r))}else{return gn(r)}}}function Sn(e){return{tasks:[],elts:[e]}}function En(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ce(ue(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){b().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ce(ue(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Cn(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=a(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=On(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const c in n){if(n.hasOwnProperty(c)){if(i[c]==null){i[c]=n[c]}}}}return Cn(ce(u(r)),e,o,i,s)}function On(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return Cn(e,"hx-vars",true,n,t)}function Tn(e,t,n){return Cn(e,"hx-vals",false,n,t)}function Rn(e,t){return le(Hn(e,t),Tn(e,t))}function qn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function An(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function T(e,t){return t.test(e.getAllResponseHeaders())}function Ln(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:w(r)||ve,returnPromise:true})}else{let e=w(r.target);if(r.target&&!e||r.source&&!e&&!w(r.source)){e=ve}return he(t,n,w(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return he(t,n,null,null,{returnPromise:true})}}function Nn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function In(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Q.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",le({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function kn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=te().body}const M=i.handler||Vn;const F=i.select||null;if(!se(r)){re(s);return e}const c=i.targetOverride||ce(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:ne(r,"hx-target")});re(l);return e}let u=oe(r);const f=u.lastButtonClicked;if(f){const A=ee(f,"formaction");if(A!=null){n=A}const L=ee(f,"formmethod");if(L!=null){if(de.includes(L.toLowerCase())){t=L}else{re(s);return e}}}const a=ne(r,"hx-confirm");if(k===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(ae(r,"htmx:confirm",G)===false){re(s);return e}}let h=r;let d=ne(r,"hx-sync");let p=null;let B=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ce(ue(r,I))}d=(N[1]||"drop").trim();u=oe(h);if(d==="drop"&&u.xhr&&u.abortable!==true){re(s);return e}else if(d==="abort"){if(u.xhr){re(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const D=oe(o);if(D&&D.triggerSpec&&D.triggerSpec.queue){p=D.triggerSpec.queue}}if(p==null){p="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(p==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="all"){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){he(t,n,r,o,i)})}re(s);return e}}const g=new XMLHttpRequest;u.xhr=g;u.abortable=B;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const X=ne(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:c})){re(s);m();return e}}if(a&&!k){if(!confirm(a)){re(s);m();return e}}let x=mn(r,c,y);if(t!=="get"&&!vn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=le(x,i.headers)}const U=dn(r,t);let b=U.errors;const V=U.formData;if(i.values){hn(V,Dn(i.values))}const j=Dn(Rn(r,o));const v=hn(V,j);let w=yn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=location.href}const S=Cn(r,"hx-request");const $=oe(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:kn(w),unfilteredFormData:v,unfilteredParameters:kn(v),headers:x,elt:r,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){re(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);re(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let H=n;if(E){H=z;const Z=!w.keys().next().done;if(Z){if(H.indexOf("?")<0){H+="?"}else{H+="&"}H+=gn(w);if(O){H+="#"+O}}}if(!In(r,H,C)){fe(r,"htmx:invalidPath",C);re(l);m();return e}g.open(t.toUpperCase(),H,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const P in x){if(x.hasOwnProperty(P)){const Y=x[P];qn(g,P,Y)}}}const T={xhr:g,target:c,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:H,responsePath:null,anchor:O}};g.onload=function(){try{const t=Nn(r);T.pathInfo.responsePath=An(g);M(r,T);if(T.keepIndicators!==true){rn(R,q)}ae(r,"htmx:afterRequest",T);ae(r,"htmx:afterOnLoad",T);if(!se(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(se(n)){e=n}}if(e){ae(e,"htmx:afterRequest",T);ae(e,"htmx:afterOnLoad",T)}}re(s)}catch(e){fe(r,"htmx:onLoadError",le({error:e},T));throw e}finally{m()}};g.onerror=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendError",T);re(l);m()};g.onabort=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendAbort",T);re(l);m()};g.ontimeout=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:timeout",T);re(l);m()};if(!ae(r,"htmx:beforeRequest",T)){re(s);m();return e}var R=tn(r);var q=nn(r);ie(["loadstart","loadend","progress","abort"],function(t){ie([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",T);const J=E?null:wn(g,r,w);g.send(J);return e}function Mn(e,t){const n=t.xhr;let r=null;let o=null;if(T(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(T(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(T(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=t.etc.push||ne(e,"hx-push-url");const c=t.etc.replace||ne(e,"hx-replace-url");const u=oe(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(c){f="replace";a=c}else if(u){f="push";a=s||i}if(a){if(a==="false"){return{}}if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Fn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Bn(e){for(var t=0;t`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Ft(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};b().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/app/assets/styles.css b/app/assets/styles.css index 03dc9b3..04c2b98 100644 --- a/app/assets/styles.css +++ b/app/assets/styles.css @@ -1,4 +1,4 @@ -/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */ +/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { @@ -340,7 +340,7 @@ padding: 0; margin: -1px; overflow: hidden; - clip: rect(0, 0, 0, 0); + clip-path: inset(50%); white-space: nowrap; border-width: 0; } @@ -455,6 +455,9 @@ .inline-block { display: inline-block; } + .inline-flex { + display: inline-flex; + } .table { display: table; } @@ -466,6 +469,10 @@ width: calc(var(--spacing) * 2.5); height: calc(var(--spacing) * 2.5); } + .size-3 { + width: calc(var(--spacing) * 3); + height: calc(var(--spacing) * 3); + } .size-4 { width: calc(var(--spacing) * 4); height: calc(var(--spacing) * 4); @@ -515,6 +522,9 @@ .h-10 { height: calc(var(--spacing) * 10); } + .h-11 { + height: calc(var(--spacing) * 11); + } .h-12 { height: calc(var(--spacing) * 12); } @@ -629,6 +639,9 @@ .max-w-full { max-width: 100%; } + .max-w-lg { + max-width: var(--container-lg); + } .max-w-md { max-width: var(--container-md); } @@ -928,6 +941,9 @@ .border-indigo-500 { border-color: var(--color-indigo-500); } + .border-input { + border-color: var(--color-input); + } .border-lime-200 { border-color: var(--color-lime-200); } @@ -1012,6 +1028,12 @@ .bg-background { background-color: var(--color-background); } + .bg-background\/50 { + background-color: var(--color-background); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-background) 50%, transparent); + } + } .bg-background\/80 { background-color: var(--color-background); @supports (color: color-mix(in lab, red, red)) { @@ -1126,6 +1148,9 @@ .bg-teal-500 { background-color: var(--color-teal-500); } + .bg-transparent { + background-color: transparent; + } .bg-violet-100 { background-color: var(--color-violet-100); } @@ -1310,6 +1335,9 @@ .py-0 { padding-block: calc(var(--spacing) * 0); } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } .py-1\.5 { padding-block: calc(var(--spacing) * 1.5); } @@ -1322,12 +1350,12 @@ .py-\[7px\] { padding-block: 7px; } + .pt-2 { + padding-top: calc(var(--spacing) * 2); + } .pt-4 { padding-top: calc(var(--spacing) * 4); } - .pr-2 { - padding-right: calc(var(--spacing) * 2); - } .pr-9 { padding-right: calc(var(--spacing) * 9); } @@ -1371,6 +1399,10 @@ font-size: var(--text-4xl); line-height: var(--tw-leading, var(--text-4xl--line-height)); } + .text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); @@ -1418,6 +1450,12 @@ .text-amber-500 { color: var(--color-amber-500); } + .text-amber-500\/20 { + color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-amber-500) 20%, transparent); + } + } .text-amber-600 { color: var(--color-amber-600); } @@ -1439,24 +1477,6 @@ .text-foreground { color: var(--color-foreground); } - .text-foreground\/50 { - color: var(--color-foreground); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-foreground) 50%, transparent); - } - } - .text-foreground\/60 { - color: var(--color-foreground); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-foreground) 60%, transparent); - } - } - .text-foreground\/80 { - color: var(--color-foreground); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-foreground) 80%, transparent); - } - } .text-fuchsia-500 { color: var(--color-fuchsia-500); } @@ -1536,6 +1556,10 @@ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .shadow-xs { + --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } .outline { outline-style: var(--tw-outline-style); outline-width: 1px; @@ -1544,7 +1568,12 @@ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .transition { - transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events; + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-\[color\,box-shadow\] { + transition-property: color,box-shadow; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } @@ -1553,19 +1582,16 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .outline-none { + --tw-outline-style: none; + outline-style: none; + } .group-open\:rounded-b-none { &:is(:where(.group):is([open], :popover-open, :open) *) { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } - .group-hover\:text-foreground { - &:is(:where(.group):hover *) { - @media (hover: hover) { - color: var(--color-foreground); - } - } - } .group-data-copied\:block { &:is(:where(.group)[data-copied] *) { display: block; @@ -1576,29 +1602,20 @@ display: none; } } - .group-\[\.copied\]\:block { - &:is(:where(.group):is(.copied) *) { - display: block; - } - } - .group-\[\.copied\]\:hidden { - &:is(:where(.group):is(.copied) *) { - display: none; + .selection\:bg-primary { + & *::selection { + background-color: var(--color-primary); } - } - .group-\[data-copied\]\:hidden { - &:is(:where(.group):is(data-copied) *) { - display: none; + &::selection { + background-color: var(--color-primary); } } - .group-\[data-copied\=true\]\:block { - &:is(:where(.group):is(data-copied=true) *) { - display: block; + .selection\:text-primary-foreground { + & *::selection { + color: var(--color-primary-foreground); } - } - .group-\[data-copied\=true\]\:hidden { - &:is(:where(.group):is(data-copied=true) *) { - display: none; + &::selection { + color: var(--color-primary-foreground); } } .first\:rounded-t-lg { @@ -1704,6 +1721,16 @@ color: var(--color-destructive); } } + .has-checked\:bg-accent { + &:has(*:checked) { + background-color: var(--color-accent); + } + } + .has-checked\:text-accent-foreground { + &:has(*:checked) { + color: var(--color-accent-foreground); + } + } .max-lg\:hidden { @media (width < 64rem) { display: none; @@ -1786,6 +1813,12 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } + .md\:text-sm { + @media (width >= 48rem) { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + } .lg\:grid-cols-4 { @media (width >= 64rem) { grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -1891,6 +1924,14 @@ border-color: var(--color-yellow-900); } } + .dark\:bg-input\/30 { + &:is(.dark *) { + background-color: var(--color-input); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-input) 30%, transparent); + } + } + } .dark\:from-amber-950\/50 { &:is(.dark *) { --tw-gradient-from: color-mix(in srgb, oklch(27.9% 0.077 45.635) 50%, transparent); @@ -2193,6 +2234,27 @@ } } } + .has-checked\:dark\:bg-accent\/50 { + &:has(*:checked) { + &:is(.dark *) { + background-color: var(--color-accent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-accent) 50%, transparent); + } + } + } + } + .\[\&_svg\]\:size-3 { + & svg { + width: calc(var(--spacing) * 3); + height: calc(var(--spacing) * 3); + } + } + .\[\&_svg\]\:\!text-destructive { + & svg { + color: var(--color-destructive) !important; + } + } .\[\&\>h2\]\:text-amber-600 { &>h2 { color: var(--color-amber-600); @@ -2251,9 +2313,14 @@ top: calc(1/2 * 100%); } } - .\[\&\>svg\]\:left-2 { + .\[\&\>svg\]\:left-3 { + &>svg { + left: calc(var(--spacing) * 3); + } + } + .\[\&\>svg\]\:-ml-1 { &>svg { - left: calc(var(--spacing) * 2); + margin-left: calc(var(--spacing) * -1); } } .\[\&\>svg\]\:size-3 { @@ -2268,12 +2335,24 @@ height: calc(var(--spacing) * 4); } } + .\[\&\>svg\]\:size-5 { + &>svg { + width: calc(var(--spacing) * 5); + height: calc(var(--spacing) * 5); + } + } .\[\&\>svg\]\:size-6 { &>svg { width: calc(var(--spacing) * 6); height: calc(var(--spacing) * 6); } } + .\[\&\>svg\]\:size-9 { + &>svg { + width: calc(var(--spacing) * 9); + height: calc(var(--spacing) * 9); + } + } .\[\&\>svg\]\:shrink-0 { &>svg { flex-shrink: 0; @@ -2290,6 +2369,101 @@ animation: var(--animate-spin); } } + .\[\&\>svg\]\:stroke-\[1\.25\] { + &>svg { + stroke-width: 1.25; + } + } + .\[\&\>svg\]\:\!text-amber-500 { + &>svg { + color: var(--color-amber-500) !important; + } + } + .\[\&\>svg\]\:\!text-blue-500 { + &>svg { + color: var(--color-blue-500) !important; + } + } + .\[\&\>svg\]\:\!text-cyan-500 { + &>svg { + color: var(--color-cyan-500) !important; + } + } + .\[\&\>svg\]\:\!text-emerald-500 { + &>svg { + color: var(--color-emerald-500) !important; + } + } + .\[\&\>svg\]\:\!text-fuchsia-500 { + &>svg { + color: var(--color-fuchsia-500) !important; + } + } + .\[\&\>svg\]\:\!text-green-500 { + &>svg { + color: var(--color-green-500) !important; + } + } + .\[\&\>svg\]\:\!text-indigo-500 { + &>svg { + color: var(--color-indigo-500) !important; + } + } + .\[\&\>svg\]\:\!text-lime-500 { + &>svg { + color: var(--color-lime-500) !important; + } + } + .\[\&\>svg\]\:\!text-neutral-500 { + &>svg { + color: var(--color-neutral-500) !important; + } + } + .\[\&\>svg\]\:\!text-orange-500 { + &>svg { + color: var(--color-orange-500) !important; + } + } + .\[\&\>svg\]\:\!text-pink-500 { + &>svg { + color: var(--color-pink-500) !important; + } + } + .\[\&\>svg\]\:\!text-purple-500 { + &>svg { + color: var(--color-purple-500) !important; + } + } + .\[\&\>svg\]\:\!text-red-500 { + &>svg { + color: var(--color-red-500) !important; + } + } + .\[\&\>svg\]\:\!text-rose-500 { + &>svg { + color: var(--color-rose-500) !important; + } + } + .\[\&\>svg\]\:\!text-sky-500 { + &>svg { + color: var(--color-sky-500) !important; + } + } + .\[\&\>svg\]\:\!text-teal-500 { + &>svg { + color: var(--color-teal-500) !important; + } + } + .\[\&\>svg\]\:\!text-violet-500 { + &>svg { + color: var(--color-violet-500) !important; + } + } + .\[\&\>svg\]\:\!text-yellow-500 { + &>svg { + color: var(--color-yellow-500) !important; + } + } .\[\&\>svg\]\:text-amber-600 { &>svg { color: var(--color-amber-600); @@ -2945,7 +3119,6 @@ } @layer components { .button-group { - isolation: isolate; display: inline-flex; width: fit-content; align-items: stretch; @@ -3210,7 +3383,6 @@ width: 100%; max-width: calc(100% - 2rem); --tw-translate-x: calc(calc(1/2 * 100%) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); flex-direction: column; @@ -3523,7 +3695,6 @@ width: 100%; max-width: calc(100% - 2rem); --tw-translate-x: calc(calc(1/2 * 100%) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); flex-direction: column; @@ -4184,7 +4355,6 @@ width: calc(var(--spacing) * 2); height: calc(var(--spacing) * 2); --tw-translate-x: calc(calc(1/2 * 100%) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); border-radius: calc(infinity * 1px); @@ -4395,6 +4565,24 @@ *:not(select).select { position: relative; display: inline-flex; + & > button { + justify-content: space-between; + --tw-font-weight: var(--font-weight-normal); + font-weight: var(--font-weight-normal); + &[aria-invalid='true'] { + border-color: var(--color-destructive); + --tw-ring-color: var(--color-destructive); + @supports (color: color-mix(in lab, red, red)) { + --tw-ring-color: color-mix(in oklab, var(--color-destructive) 20%, transparent); + } + &:is(.dark *) { + --tw-ring-color: var(--color-destructive); + @supports (color: color-mix(in lab, red, red)) { + --tw-ring-color: color-mix(in oklab, var(--color-destructive) 40%, transparent); + } + } + } + } [data-popover] { padding: calc(var(--spacing) * 1); [role='option'] { @@ -4993,7 +5181,6 @@ transition-duration: var(--tw-duration, var(--default-transition-duration)); } &::before { - content: var(--tw-content); --tw-content: ''; content: var(--tw-content); } @@ -5406,12 +5593,12 @@ text-overflow: ellipsis; white-space: nowrap; border-radius: var(--radius-md); - background-color: var(--color-primary); + background-color: var(--color-foreground); padding-inline: calc(var(--spacing) * 3); padding-block: calc(var(--spacing) * 1.5); font-size: var(--text-xs); line-height: var(--tw-leading, var(--text-xs--line-height)); - color: var(--color-primary-foreground); + color: var(--color-background); opacity: 0%; transition-property: all; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); diff --git a/app/config.py b/app/config.py index d5ef798..b78f91a 100644 --- a/app/config.py +++ b/app/config.py @@ -18,6 +18,17 @@ class ImageSetting(BaseModel): model_config = {"extra": "ignore"} +class DetectionSetting(BaseModel): + priority: int = 0 + any_files: list[str] = [] + all_files: list[str] = [] + any_paths: list[str] = [] + none_files: list[str] = [] + package_check: str | None = None + + model_config = {"extra": "ignore"} + + class PresetSetting(BaseModel): slug: str name: str @@ -29,6 +40,7 @@ class PresetSetting(BaseModel): logo: str root_directory: str | None = None beta: bool | None = None + detection: DetectionSetting | None = None model_config = {"extra": "ignore"} @@ -98,6 +110,7 @@ class Settings(BaseSettings): redis_url: str = "redis://redis:6379" docker_host: str = "tcp://docker-proxy:2375" data_dir: str = "/data" + host_data_dir: str | None = None app_dir: str = "/app" upload_dir: str = "" traefik_dir: str = "" @@ -108,12 +121,15 @@ class Settings(BaseSettings): max_cpus: float | None = None default_memory_mb: int | None = None max_memory_mb: int | None = None + default_db_size_limit_bytes: int | None = 5 * 1024 * 1024 * 1024 presets: list[dict] = [] images: list[dict] = [] job_timeout: int = 320 job_completion_wait: int = 300 deployment_timeout: int = 300 container_delete_grace_seconds: int = 3 + service_uid: int = 1000 + service_gid: int = 1000 db_echo: bool = False log_level: str = "WARNING" env: str = "production" @@ -208,6 +224,8 @@ def get_settings(): settings.config_file = os.path.join(settings.data_dir, "config.json") if not settings.version_file: settings.version_file = os.path.join(settings.data_dir, "version.json") + if not settings.host_data_dir: + settings.host_data_dir = settings.data_dir # Load presets/images from files (data-dir overrides core) core_presets_file = Path(settings.app_dir) / "settings" / "presets.json" diff --git a/app/dependencies.py b/app/dependencies.py index bf899d7..1b363da 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -7,7 +7,7 @@ from authlib.integrations.starlette_client import OAuth from fastapi.templating import Jinja2Templates from jinja2 import pass_context -from sqlalchemy import select +from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from authlib.jose import jwt @@ -19,7 +19,7 @@ from config import get_settings, Settings from db import get_db -from models import User, Project, Deployment, Team, TeamMember, utc_now +from models import User, Project, Deployment, Team, TeamMember, Storage, utc_now from services.github import GitHubService from services.github_installation import GitHubInstallationService @@ -217,14 +217,7 @@ def decode_jwt_claims( def _clear_auth_cookie_header(settings: Settings) -> str: secure = "Secure; " if settings.url_scheme == "https" else "" - return ( - "auth_token=; " - "Path=/; " - "Max-Age=0; " - "HttpOnly; " - "SameSite=Lax; " - f"{secure}" - ) + return f"auth_token=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax; {secure}" def _refresh_auth_token( @@ -382,7 +375,7 @@ async def get_project_by_name( result = await db.execute( select(Project) .where( - Project.name == project_name, + func.lower(Project.name) == project_name.lower(), Project.team_id == team.id, Project.status != "deleted", ) @@ -437,6 +430,27 @@ async def get_deployment_by_id( return deployment +async def get_storage_by_name( + storage_name: str, + db: AsyncSession = Depends(get_db), + team_and_membership: tuple[Team, TeamMember] = Depends(get_team_by_slug), +) -> Storage: + team, membership = team_and_membership + + result = await db.execute( + select(Storage).where( + func.lower(Storage.name) == storage_name.lower(), + Storage.team_id == team.id, + Storage.type.in_(["database", "volume"]), + Storage.status != "deleted", + ) + ) + storage = result.scalar_one_or_none() + if not storage: + raise HTTPException(status_code=404, detail="Storage not found") + return storage + + def is_superadmin(user: User) -> bool: return user.id == 1 @@ -513,6 +527,8 @@ def time_ago_filter(value): templates = Jinja2Templates( directory="templates", auto_reload=settings.env == "development" ) +if settings.env == "development": + templates.env.cache = {} templates.env.globals["_"] = get_translation templates.env.globals["app_name"] = settings.app_name templates.env.globals["app_description"] = settings.app_description diff --git a/app/forms/project.py b/app/forms/project.py index 99e39d9..2226e51 100644 --- a/app/forms/project.py +++ b/app/forms/project.py @@ -224,7 +224,7 @@ def validate_slug(self, field): ) -class ProjectDeleteEnvironmentForm(StarletteForm): +class ProjectEnvironmentRemoveForm(StarletteForm): environment_id = HiddenField(validators=[DataRequired()]) confirm = StringField(_l("Confirmation"), validators=[DataRequired()]) submit = SubmitField(_l("Delete")) @@ -343,7 +343,7 @@ def validate_environment_id(self, field): raise ValidationError(_("Environment not found.")) -class ProjectRemoveDomainForm(StarletteForm): +class ProjectDomainRemoveForm(StarletteForm): domain_id = HiddenField(validators=[DataRequired()]) confirm = StringField(_l("Confirmation"), validators=[DataRequired()]) @@ -374,7 +374,7 @@ def validate_confirm(self, field): raise ValidationError(_("Domain confirmation did not match.")) -class ProjectVerifyDomainForm(StarletteForm): +class ProjectDomainVerifyForm(StarletteForm): domain_id = HiddenField(validators=[DataRequired()]) def __init__(self, *args, domains: list[Domain], **kwargs): @@ -391,7 +391,7 @@ def validate_domain_id(self, field): raise ValidationError(_("Domain is already verified.")) -class ProjectBuildAndProjectDeployForm(StarletteForm): +class ProjectBuildAndDeployForm(StarletteForm): preset = SelectField( _l("Framework presets"), validators=[Optional(), Length(max=255)], @@ -482,17 +482,18 @@ async def async_validate_name(self, field): select(Project).where( func.lower(Project.name) == field.data.lower(), Project.team_id == self.team.id, - Project.status != "deleted", Project.id != self.project.id, ) ) if result.scalar_one_or_none(): raise ValidationError( - _("A project with this name already exists in this team.") + _( + "A project with this name already exists in this team or is reserved." + ) ) -class NewProjectForm(StarletteForm): +class ProjectCreateForm(StarletteForm): name = StringField( _l("Project name"), validators=[ @@ -566,12 +567,13 @@ async def async_validate_name(self, field): select(Project).where( func.lower(Project.name) == field.data.lower(), Project.team_id == self.team.id, - Project.status != "deleted", ) ) if result.scalar_one_or_none(): raise ValidationError( - _("A project with this name already exists in this team.") + _( + "A project with this name already exists in this team or is reserved." + ) ) validate_image = validate_image @@ -592,16 +594,16 @@ class ProjectDeployForm(StarletteForm): submit = SubmitField(_l("Deploy")) -class ProjectCancelDeploymentForm(StarletteForm): +class ProjectDeploymentCancelForm(StarletteForm): submit = SubmitField(_l("Cancel")) -class ProjectRollbackDeploymentForm(StarletteForm): +class ProjectDeploymentRollbackForm(StarletteForm): environment_id = HiddenField(_l("Environment ID"), validators=[DataRequired()]) submit = SubmitField(_l("Rollback")) -class ProjectPromoteDeploymentForm(StarletteForm): +class ProjectDeploymentPromoteForm(StarletteForm): environment_id = HiddenField(_l("Environment ID"), validators=[DataRequired()]) deployment_id = HiddenField(_l("Deployment ID"), validators=[DataRequired()]) submit = SubmitField(_l("Promote")) diff --git a/app/forms/storage.py b/app/forms/storage.py new file mode 100644 index 0000000..a928fd2 --- /dev/null +++ b/app/forms/storage.py @@ -0,0 +1,230 @@ +import json +from starlette.requests import Request +from starlette_wtf import StarletteForm +from wtforms import HiddenField, StringField, SubmitField, SelectField +from wtforms.validators import DataRequired, Length, Regexp, ValidationError, Optional +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from dependencies import get_translation as _, get_lazy_translation as _l +from models import Project, Storage, StorageProject, Team + + +def _parse_environment_ids(value): + if value in (None, "", []): + return [] + if isinstance(value, list): + parsed = value + else: + try: + parsed = json.loads(value) + except (TypeError, json.JSONDecodeError): + return None + if parsed is None: + return [] + if not isinstance(parsed, list): + return None + environment_ids = [] + for item in parsed: + if item in (None, ""): + continue + if not isinstance(item, str): + return None + environment_ids.append(item) + return list(dict.fromkeys(environment_ids)) + + +class StorageCreateForm(StarletteForm): + type = SelectField( + _l("Type"), + choices=[ + ("database", _("Database")), + ("volume", _("Volume")), + ], + ) + name = StringField( + _l("Name"), + validators=[ + DataRequired(), + Length(min=1, max=100), + Regexp( + r"^[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]$", + message=_l( + "Storage names can only contain letters, numbers, hyphens, underscores and dots. They cannot start or end with a dot, underscore or hyphen." + ), + ), + ], + ) + submit = SubmitField(_l("Create storage")) + environment_ids = StringField(_l("Environments"), validators=[Optional()]) + + def __init__( + self, + request: Request, + *args, + db: AsyncSession, + team: Team, + project: Project | None = None, + **kwargs, + ): + super().__init__(request, *args, **kwargs) + self.db = db + self.team = team + self.project = project + + async def async_validate_name(self, field): + if self.db and self.team: + result = await self.db.execute( + select(Storage).where( + func.lower(Storage.name) == field.data.lower(), + Storage.team_id == self.team.id, + ) + ) + if result.scalar_one_or_none(): + raise ValidationError( + _( + "A storage with this name already exists in this team or is reserved." + ) + ) + + def validate_environment_ids(self, field): + if not self.project: + return + environment_ids = _parse_environment_ids(field.data) + if environment_ids is None: + raise ValidationError(_("Invalid environment selection.")) + field.data = environment_ids + for environment_id in environment_ids: + if not self.project.get_environment_by_id(environment_id): + raise ValidationError(_("Environment not found.")) + + +class StorageDeleteForm(StarletteForm): + name = HiddenField(_l("Storage name"), validators=[DataRequired()]) + confirm = StringField(_l("Confirmation"), validators=[DataRequired()]) + submit = SubmitField(_l("Delete"), name="delete_storage") + + def validate_confirm(self, field): + if field.data != self.name.data: # type: ignore + raise ValidationError(_("Storage name confirmation did not match.")) + + +class StorageProjectForm(StarletteForm): + association_id = HiddenField() + storage_id = HiddenField(_l("Storage"), validators=[DataRequired()]) + project_id = StringField(_l("Project"), validators=[DataRequired()]) + environment_ids = StringField(_l("Environments"), validators=[Optional()]) + + def __init__( + self, + request: Request, + *args, + storage: Storage | None = None, + storages: list[Storage] | None = None, + projects: list[Project], + associations: list["StorageProject"], + **kwargs, + ): + super().__init__(request, *args, **kwargs) + self.storage = storage + self.storages = storages or [] + self.projects = projects + self.associations = associations + self._projects_by_id = {project.id: project for project in projects} + self._storages_by_id = {storage.id: storage for storage in self.storages} + self._associations_by_id = { + str(association.id): association for association in associations + } + self._selected_project = None + self._selected_storage = None + self.association = None + if self.environment_ids.data in (None, ""): + self.environment_ids.data = [] + + def _parse_environment_ids(self, value): + return _parse_environment_ids(value) + + def validate_association_id(self, field): + if not field.data: + return + association = self._associations_by_id.get(field.data) + if not association: + raise ValidationError(_("Association not found.")) + if self.storage and association.storage_id != self.storage.id: + raise ValidationError(_("Association not found.")) + self.association = association + + def validate_storage_id(self, field): + if self.storage: + if field.data != self.storage.id: + raise ValidationError(_("Storage not found.")) + elif self._storages_by_id: + storage = self._storages_by_id.get(field.data) + if not storage: + raise ValidationError(_("Storage not found.")) + self._selected_storage = storage + else: + raise ValidationError(_("Storage not found.")) + if self.association and field.data != self.association.storage_id: + raise ValidationError(_("Storage cannot be changed.")) + + def validate_project_id(self, field): + project = self._projects_by_id.get(field.data) + if not project: + raise ValidationError(_("Project not found.")) + if self.association and field.data != self.association.project_id: + raise ValidationError(_("Project cannot be changed.")) + self._selected_project = project + + def validate_environment_ids(self, field): + if not self._selected_project and self.project_id.data: + self._selected_project = self._projects_by_id.get(self.project_id.data) + if not self._selected_project: + return + environment_ids = self._parse_environment_ids(field.data) + if environment_ids is None: + raise ValidationError(_("Invalid environment selection.")) + environment_ids = list(dict.fromkeys(environment_ids)) + field.data = environment_ids + for environment_id in environment_ids: + if not self._selected_project.get_environment_by_id(environment_id): + raise ValidationError(_("Environment not found.")) + association_id = self.association_id.data + for association in self.associations: + if association.project_id != self.project_id.data: + continue + if association.storage_id != self.storage_id.data: + continue + if association_id and str(association.id) == association_id: + continue + raise ValidationError( + _("This project is already connected to this storage.") + ) + + +class StorageProjectRemoveForm(StarletteForm): + association_id = HiddenField(_l("Association ID"), validators=[DataRequired()]) + confirm = StringField(_l("Confirmation"), validators=[DataRequired()]) + + def __init__( + self, request: Request, *args, associations: list["StorageProject"], **kwargs + ): + super().__init__(request, *args, **kwargs) + self.associations = associations + self._associations_by_id = { + str(association.id): association for association in associations + } + self.association = None + + def validate_association_id(self, field): + association = self._associations_by_id.get(field.data) + if not association: + raise ValidationError(_("Association not found.")) + self.association = association + + def validate_confirm(self, field): + if not self.association: + return + project_name = self.association.project.name if self.association.project else "" + if field.data != project_name: + raise ValidationError(_("Project name confirmation did not match.")) diff --git a/app/forms/team.py b/app/forms/team.py index 26744eb..79eb466 100644 --- a/app/forms/team.py +++ b/app/forms/team.py @@ -28,7 +28,7 @@ ] -class NewTeamForm(StarletteForm): +class TeamCreateForm(StarletteForm): name = StringField(_l("Name"), validators=[DataRequired(), Length(min=1, max=100)]) submit = SubmitField(_l("Create team")) @@ -102,7 +102,7 @@ def validate_confirm(self, field): raise ValidationError(_("Team slug confirmation did not match.")) -class TeamAddMemberForm(StarletteForm): +class TeamMemberAddForm(StarletteForm): email = StringField( _l("Email"), validators=[ @@ -153,7 +153,7 @@ async def async_validate_email(self, field): ) -class TeamDeleteMemberForm(StarletteForm): +class TeamMemberRemoveForm(StarletteForm): email = HiddenField( validators=[ DataRequired(), diff --git a/app/forms/user.py b/app/forms/user.py index 8db5776..1627260 100644 --- a/app/forms/user.py +++ b/app/forms/user.py @@ -94,7 +94,7 @@ def validate_confirm(self, field): raise ValidationError(_("Email confirmation did not match.")) -class UserRevokeOAuthAccessForm(StarletteForm): +class UserOAuthAccessRevokeForm(StarletteForm): provider = SelectField( _l("Provider"), default="", diff --git a/app/migrations/versions/31fe53421216_tokens_invalid_before.py b/app/migrations/versions/31fe53421216_tokens_invalid_before.py index adadcf9..b36a8e9 100644 --- a/app/migrations/versions/31fe53421216_tokens_invalid_before.py +++ b/app/migrations/versions/31fe53421216_tokens_invalid_before.py @@ -1,4 +1,4 @@ -"""Add tokens invalid before +"""Add tokens invalid before column to user table Revision ID: 31fe53421216 Revises: 87a893d57c86 diff --git a/app/migrations/versions/454328a03102_initial.py b/app/migrations/versions/454328a03102_initial.py index ca89e53..d28be89 100644 --- a/app/migrations/versions/454328a03102_initial.py +++ b/app/migrations/versions/454328a03102_initial.py @@ -1,10 +1,11 @@ -"""Initial +"""Initial schema Revision ID: 454328a03102 -Revises: +Revises: Create Date: 2025-08-21 03:10:16.158338 """ + from typing import Sequence, Union from alembic import op @@ -12,7 +13,7 @@ # revision identifiers, used by Alembic. -revision: str = '454328a03102' +revision: str = "454328a03102" down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,238 +22,424 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('github_installation', - sa.Column('installation_id', sa.Integer(), nullable=False), - sa.Column('token', sa.String(length=2048), nullable=True), - sa.Column('token_expires_at', sa.DateTime(), nullable=True), - sa.Column('status', sa.Enum('active', 'deleted', 'suspended', name='github_installation_status'), nullable=False), - sa.PrimaryKeyConstraint('installation_id') - ) - op.create_table('team', - sa.Column('id', sa.String(length=32), nullable=False), - sa.Column('name', sa.String(length=100), nullable=False), - sa.Column('slug', sa.String(length=40), nullable=True), - sa.Column('has_avatar', sa.Boolean(), nullable=False), - sa.Column('status', sa.Enum('active', 'deleted', name='team_status'), nullable=False), - sa.Column('created_by_user_id', sa.Integer(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['created_by_user_id'], ['user.id'], ondelete='SET NULL', use_alter=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('slug') - ) - op.create_index(op.f('ix_team_created_at'), 'team', ['created_at'], unique=False) - op.create_index(op.f('ix_team_name'), 'team', ['name'], unique=False) - op.create_index(op.f('ix_team_updated_at'), 'team', ['updated_at'], unique=False) - op.create_table('project', - sa.Column('id', sa.String(length=32), nullable=False), - sa.Column('name', sa.String(length=100), nullable=False), - sa.Column('has_avatar', sa.Boolean(), nullable=False), - sa.Column('repo_id', sa.BigInteger(), nullable=False), - sa.Column('repo_full_name', sa.String(length=255), nullable=False), - sa.Column('repo_status', sa.Enum('active', 'deleted', 'removed', 'transferred', name='project_github_status'), nullable=False), - sa.Column('github_installation_id', sa.Integer(), nullable=False), - sa.Column('environments', sa.JSON(), nullable=False), - sa.Column('env_vars', sa.Text(), nullable=False), - sa.Column('slug', sa.String(length=40), nullable=True), - sa.Column('config', sa.JSON(), nullable=False), - sa.Column('created_by_user_id', sa.Integer(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.Column('status', sa.Enum('active', 'paused', 'deleted', name='project_status'), nullable=False), - sa.Column('team_id', sa.String(length=32), nullable=False), - sa.ForeignKeyConstraint(['created_by_user_id'], ['user.id'], ondelete='SET NULL', use_alter=True), - sa.ForeignKeyConstraint(['github_installation_id'], ['github_installation.installation_id'], ), - sa.ForeignKeyConstraint(['team_id'], ['team.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('slug'), - sa.UniqueConstraint('team_id', 'name', name='uq_project_team_name') - ) - op.create_index(op.f('ix_project_created_at'), 'project', ['created_at'], unique=False) - op.create_index(op.f('ix_project_github_installation_id'), 'project', ['github_installation_id'], unique=False) - op.create_index(op.f('ix_project_name'), 'project', ['name'], unique=False) - op.create_index(op.f('ix_project_repo_full_name'), 'project', ['repo_full_name'], unique=False) - op.create_index(op.f('ix_project_repo_id'), 'project', ['repo_id'], unique=False) - op.create_index(op.f('ix_project_team_id'), 'project', ['team_id'], unique=False) - op.create_index(op.f('ix_project_updated_at'), 'project', ['updated_at'], unique=False) - op.create_table('user', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('email', sa.String(length=320), nullable=False), - sa.Column('username', sa.String(length=50), nullable=False), - sa.Column('name', sa.String(length=256), nullable=True), - sa.Column('email_verified', sa.Boolean(), nullable=False), - sa.Column('has_avatar', sa.Boolean(), nullable=False), - sa.Column('status', sa.Enum('active', 'deleted', name='team_status'), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.Column('default_team_id', sa.String(length=32), nullable=True), - sa.ForeignKeyConstraint(['default_team_id'], ['team.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_user_created_at'), 'user', ['created_at'], unique=False) - op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) - op.create_index(op.f('ix_user_name'), 'user', ['name'], unique=False) - op.create_index(op.f('ix_user_updated_at'), 'user', ['updated_at'], unique=False) - op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True) - op.create_table('deployment', - sa.Column('id', sa.String(length=32), nullable=False), - sa.Column('project_id', sa.String(length=32), nullable=False), - sa.Column('repo_id', sa.BigInteger(), nullable=False), - sa.Column('repo_full_name', sa.String(length=255), nullable=False), - sa.Column('environment_id', sa.String(length=8), nullable=False), - sa.Column('branch', sa.String(length=255), nullable=False), - sa.Column('commit_sha', sa.String(length=40), nullable=False), - sa.Column('commit_meta', sa.JSON(), nullable=False), - sa.Column('config', sa.JSON(), nullable=False), - sa.Column('env_vars', sa.Text(), nullable=False), - sa.Column('job_id', sa.String(length=36), nullable=True), - sa.Column('container_id', sa.String(length=64), nullable=True), - sa.Column('container_status', sa.Enum('running', 'stopped', 'removed', name='deployment_container_status'), nullable=True), - sa.Column('status', sa.Enum('queued', 'in_progress', 'completed', name='deployment_status'), nullable=False), - sa.Column('conclusion', sa.Enum('succeeded', 'failed', 'canceled', 'skipped', name='deployment_conclusion'), nullable=True), - sa.Column('trigger', sa.Enum('webhook', 'user', 'api', name='deployment_trigger'), nullable=False), - sa.Column('created_by_user_id', sa.Integer(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('concluded_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['created_by_user_id'], ['user.id'], ondelete='SET NULL', use_alter=True), - sa.ForeignKeyConstraint(['project_id'], ['project.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_deployment_branch'), 'deployment', ['branch'], unique=False) - op.create_index(op.f('ix_deployment_commit_sha'), 'deployment', ['commit_sha'], unique=False) - op.create_index(op.f('ix_deployment_concluded_at'), 'deployment', ['concluded_at'], unique=False) - op.create_index(op.f('ix_deployment_created_at'), 'deployment', ['created_at'], unique=False) - op.create_index(op.f('ix_deployment_project_id'), 'deployment', ['project_id'], unique=False) - op.create_index(op.f('ix_deployment_repo_full_name'), 'deployment', ['repo_full_name'], unique=False) - op.create_index(op.f('ix_deployment_repo_id'), 'deployment', ['repo_id'], unique=False) - op.create_table('domain', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('project_id', sa.String(length=32), nullable=False), - sa.Column('hostname', sa.String(length=255), nullable=False), - sa.Column('type', sa.Enum('route', '301', '302', '307', '308', name='domain_type'), nullable=False), - sa.Column('environment_id', sa.String(length=8), nullable=True), - sa.Column('status', sa.Enum('pending', 'active', 'disabled', 'failed', name='domain_status'), nullable=False), - sa.Column('message', sa.Text(), nullable=True), - sa.Column('last_checked_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['project_id'], ['project.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_domain_hostname'), 'domain', ['hostname'], unique=False) - op.create_index(op.f('ix_domain_project_id'), 'domain', ['project_id'], unique=False) - op.create_table('team_invite', - sa.Column('id', sa.String(length=32), nullable=False), - sa.Column('team_id', sa.String(length=32), nullable=False), - sa.Column('email', sa.String(length=320), nullable=False), - sa.Column('role', sa.Enum('owner', 'admin', 'member', name='team_invite_role'), nullable=False), - sa.Column('status', sa.Enum('pending', 'accepted', 'revoked', name='team_invite_status'), nullable=False), - sa.Column('inviter_id', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('expires_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['inviter_id'], ['user.id'], ), - sa.ForeignKeyConstraint(['team_id'], ['team.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_team_invite_email'), 'team_invite', ['email'], unique=False) - op.create_index(op.f('ix_team_invite_inviter_id'), 'team_invite', ['inviter_id'], unique=False) - op.create_index(op.f('ix_team_invite_team_id'), 'team_invite', ['team_id'], unique=False) - op.create_table('team_member', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('team_id', sa.String(length=32), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('role', sa.Enum('owner', 'admin', 'member', name='team_member_role'), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['team_id'], ['team.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_team_member_team_id'), 'team_member', ['team_id'], unique=False) - op.create_index(op.f('ix_team_member_user_id'), 'team_member', ['user_id'], unique=False) - op.create_table('user_identity', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('provider', sa.Enum('github', 'google', name='identity_provider'), nullable=False), - sa.Column('provider_user_id', sa.String(length=100), nullable=True), - sa.Column('access_token', sa.String(length=2048), nullable=True), - sa.Column('refresh_token', sa.String(length=2048), nullable=True), - sa.Column('token_expires_at', sa.DateTime(), nullable=True), - sa.Column('password_hash', sa.String(length=255), nullable=True), - sa.Column('provider_metadata', sa.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('provider', 'provider_user_id', name='uq_identity_provider_user') - ) - op.create_index(op.f('ix_user_identity_provider'), 'user_identity', ['provider'], unique=False) - op.create_index(op.f('ix_user_identity_provider_user_id'), 'user_identity', ['provider_user_id'], unique=False) - op.create_index(op.f('ix_user_identity_user_id'), 'user_identity', ['user_id'], unique=False) - op.create_table('alias', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('subdomain', sa.String(length=63), nullable=False), - sa.Column('deployment_id', sa.String(length=32), nullable=False), - sa.Column('previous_deployment_id', sa.String(length=32), nullable=True), - sa.Column('type', sa.Enum('branch', 'environment', 'environment_id', name='alias_type'), nullable=False), - sa.Column('value', sa.String(length=255), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['deployment_id'], ['deployment.id'], ), - sa.ForeignKeyConstraint(['previous_deployment_id'], ['deployment.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('subdomain') - ) - op.create_index(op.f('ix_alias_deployment_id'), 'alias', ['deployment_id'], unique=False) - op.create_index(op.f('ix_alias_previous_deployment_id'), 'alias', ['previous_deployment_id'], unique=False) - op.create_index(op.f('ix_alias_updated_at'), 'alias', ['updated_at'], unique=False) + op.create_table( + "github_installation", + sa.Column("installation_id", sa.Integer(), nullable=False), + sa.Column("token", sa.String(length=2048), nullable=True), + sa.Column("token_expires_at", sa.DateTime(), nullable=True), + sa.Column( + "status", + sa.Enum( + "active", "deleted", "suspended", name="github_installation_status" + ), + nullable=False, + ), + sa.PrimaryKeyConstraint("installation_id"), + ) + op.create_table( + "team", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("slug", sa.String(length=40), nullable=True), + sa.Column("has_avatar", sa.Boolean(), nullable=False), + sa.Column( + "status", sa.Enum("active", "deleted", name="team_status"), nullable=False + ), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["created_by_user_id"], ["user.id"], ondelete="SET NULL", use_alter=True + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + ) + op.create_index(op.f("ix_team_created_at"), "team", ["created_at"], unique=False) + op.create_index(op.f("ix_team_name"), "team", ["name"], unique=False) + op.create_index(op.f("ix_team_updated_at"), "team", ["updated_at"], unique=False) + op.create_table( + "project", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("has_avatar", sa.Boolean(), nullable=False), + sa.Column("repo_id", sa.BigInteger(), nullable=False), + sa.Column("repo_full_name", sa.String(length=255), nullable=False), + sa.Column( + "repo_status", + sa.Enum( + "active", + "deleted", + "removed", + "transferred", + name="project_github_status", + ), + nullable=False, + ), + sa.Column("github_installation_id", sa.Integer(), nullable=False), + sa.Column("environments", sa.JSON(), nullable=False), + sa.Column("env_vars", sa.Text(), nullable=False), + sa.Column("slug", sa.String(length=40), nullable=True), + sa.Column("config", sa.JSON(), nullable=False), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column( + "status", + sa.Enum("active", "paused", "deleted", name="project_status"), + nullable=False, + ), + sa.Column("team_id", sa.String(length=32), nullable=False), + sa.ForeignKeyConstraint( + ["created_by_user_id"], ["user.id"], ondelete="SET NULL", use_alter=True + ), + sa.ForeignKeyConstraint( + ["github_installation_id"], + ["github_installation.installation_id"], + ), + sa.ForeignKeyConstraint( + ["team_id"], + ["team.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + sa.UniqueConstraint("team_id", "name", name="uq_project_team_name"), + ) + op.create_index( + op.f("ix_project_created_at"), "project", ["created_at"], unique=False + ) + op.create_index( + op.f("ix_project_github_installation_id"), + "project", + ["github_installation_id"], + unique=False, + ) + op.create_index(op.f("ix_project_name"), "project", ["name"], unique=False) + op.create_index( + op.f("ix_project_repo_full_name"), "project", ["repo_full_name"], unique=False + ) + op.create_index(op.f("ix_project_repo_id"), "project", ["repo_id"], unique=False) + op.create_index(op.f("ix_project_team_id"), "project", ["team_id"], unique=False) + op.create_index( + op.f("ix_project_updated_at"), "project", ["updated_at"], unique=False + ) + op.create_table( + "user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("username", sa.String(length=50), nullable=False), + sa.Column("name", sa.String(length=256), nullable=True), + sa.Column("email_verified", sa.Boolean(), nullable=False), + sa.Column("has_avatar", sa.Boolean(), nullable=False), + sa.Column( + "status", sa.Enum("active", "deleted", name="team_status"), nullable=False + ), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("default_team_id", sa.String(length=32), nullable=True), + sa.ForeignKeyConstraint( + ["default_team_id"], + ["team.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_user_created_at"), "user", ["created_at"], unique=False) + op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) + op.create_index(op.f("ix_user_name"), "user", ["name"], unique=False) + op.create_index(op.f("ix_user_updated_at"), "user", ["updated_at"], unique=False) + op.create_index(op.f("ix_user_username"), "user", ["username"], unique=True) + op.create_table( + "deployment", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("project_id", sa.String(length=32), nullable=False), + sa.Column("repo_id", sa.BigInteger(), nullable=False), + sa.Column("repo_full_name", sa.String(length=255), nullable=False), + sa.Column("environment_id", sa.String(length=8), nullable=False), + sa.Column("branch", sa.String(length=255), nullable=False), + sa.Column("commit_sha", sa.String(length=40), nullable=False), + sa.Column("commit_meta", sa.JSON(), nullable=False), + sa.Column("config", sa.JSON(), nullable=False), + sa.Column("env_vars", sa.Text(), nullable=False), + sa.Column("job_id", sa.String(length=36), nullable=True), + sa.Column("container_id", sa.String(length=64), nullable=True), + sa.Column( + "container_status", + sa.Enum( + "running", "stopped", "removed", name="deployment_container_status" + ), + nullable=True, + ), + sa.Column( + "status", + sa.Enum("queued", "in_progress", "completed", name="deployment_status"), + nullable=False, + ), + sa.Column( + "conclusion", + sa.Enum( + "succeeded", + "failed", + "canceled", + "skipped", + name="deployment_conclusion", + ), + nullable=True, + ), + sa.Column( + "trigger", + sa.Enum("webhook", "user", "api", name="deployment_trigger"), + nullable=False, + ), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("concluded_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["created_by_user_id"], ["user.id"], ondelete="SET NULL", use_alter=True + ), + sa.ForeignKeyConstraint( + ["project_id"], + ["project.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_deployment_branch"), "deployment", ["branch"], unique=False + ) + op.create_index( + op.f("ix_deployment_commit_sha"), "deployment", ["commit_sha"], unique=False + ) + op.create_index( + op.f("ix_deployment_concluded_at"), "deployment", ["concluded_at"], unique=False + ) + op.create_index( + op.f("ix_deployment_created_at"), "deployment", ["created_at"], unique=False + ) + op.create_index( + op.f("ix_deployment_project_id"), "deployment", ["project_id"], unique=False + ) + op.create_index( + op.f("ix_deployment_repo_full_name"), + "deployment", + ["repo_full_name"], + unique=False, + ) + op.create_index( + op.f("ix_deployment_repo_id"), "deployment", ["repo_id"], unique=False + ) + op.create_table( + "domain", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("project_id", sa.String(length=32), nullable=False), + sa.Column("hostname", sa.String(length=255), nullable=False), + sa.Column( + "type", + sa.Enum("route", "301", "302", "307", "308", name="domain_type"), + nullable=False, + ), + sa.Column("environment_id", sa.String(length=8), nullable=True), + sa.Column( + "status", + sa.Enum("pending", "active", "disabled", "failed", name="domain_status"), + nullable=False, + ), + sa.Column("message", sa.Text(), nullable=True), + sa.Column("last_checked_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["project_id"], + ["project.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_domain_hostname"), "domain", ["hostname"], unique=False) + op.create_index( + op.f("ix_domain_project_id"), "domain", ["project_id"], unique=False + ) + op.create_table( + "team_invite", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("team_id", sa.String(length=32), nullable=False), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column( + "role", + sa.Enum("owner", "admin", "member", name="team_invite_role"), + nullable=False, + ), + sa.Column( + "status", + sa.Enum("pending", "accepted", "revoked", name="team_invite_status"), + nullable=False, + ), + sa.Column("inviter_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["inviter_id"], + ["user.id"], + ), + sa.ForeignKeyConstraint( + ["team_id"], + ["team.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_team_invite_email"), "team_invite", ["email"], unique=False + ) + op.create_index( + op.f("ix_team_invite_inviter_id"), "team_invite", ["inviter_id"], unique=False + ) + op.create_index( + op.f("ix_team_invite_team_id"), "team_invite", ["team_id"], unique=False + ) + op.create_table( + "team_member", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("team_id", sa.String(length=32), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "role", + sa.Enum("owner", "admin", "member", name="team_member_role"), + nullable=False, + ), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["team_id"], + ["team.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_team_member_team_id"), "team_member", ["team_id"], unique=False + ) + op.create_index( + op.f("ix_team_member_user_id"), "team_member", ["user_id"], unique=False + ) + op.create_table( + "user_identity", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "provider", + sa.Enum("github", "google", name="identity_provider"), + nullable=False, + ), + sa.Column("provider_user_id", sa.String(length=100), nullable=True), + sa.Column("access_token", sa.String(length=2048), nullable=True), + sa.Column("refresh_token", sa.String(length=2048), nullable=True), + sa.Column("token_expires_at", sa.DateTime(), nullable=True), + sa.Column("password_hash", sa.String(length=255), nullable=True), + sa.Column("provider_metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "provider", "provider_user_id", name="uq_identity_provider_user" + ), + ) + op.create_index( + op.f("ix_user_identity_provider"), "user_identity", ["provider"], unique=False + ) + op.create_index( + op.f("ix_user_identity_provider_user_id"), + "user_identity", + ["provider_user_id"], + unique=False, + ) + op.create_index( + op.f("ix_user_identity_user_id"), "user_identity", ["user_id"], unique=False + ) + op.create_table( + "alias", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("subdomain", sa.String(length=63), nullable=False), + sa.Column("deployment_id", sa.String(length=32), nullable=False), + sa.Column("previous_deployment_id", sa.String(length=32), nullable=True), + sa.Column( + "type", + sa.Enum("branch", "environment", "environment_id", name="alias_type"), + nullable=False, + ), + sa.Column("value", sa.String(length=255), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["deployment_id"], + ["deployment.id"], + ), + sa.ForeignKeyConstraint( + ["previous_deployment_id"], + ["deployment.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("subdomain"), + ) + op.create_index( + op.f("ix_alias_deployment_id"), "alias", ["deployment_id"], unique=False + ) + op.create_index( + op.f("ix_alias_previous_deployment_id"), + "alias", + ["previous_deployment_id"], + unique=False, + ) + op.create_index(op.f("ix_alias_updated_at"), "alias", ["updated_at"], unique=False) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_alias_updated_at'), table_name='alias') - op.drop_index(op.f('ix_alias_previous_deployment_id'), table_name='alias') - op.drop_index(op.f('ix_alias_deployment_id'), table_name='alias') - op.drop_table('alias') - op.drop_index(op.f('ix_user_identity_user_id'), table_name='user_identity') - op.drop_index(op.f('ix_user_identity_provider_user_id'), table_name='user_identity') - op.drop_index(op.f('ix_user_identity_provider'), table_name='user_identity') - op.drop_table('user_identity') - op.drop_index(op.f('ix_team_member_user_id'), table_name='team_member') - op.drop_index(op.f('ix_team_member_team_id'), table_name='team_member') - op.drop_table('team_member') - op.drop_index(op.f('ix_team_invite_team_id'), table_name='team_invite') - op.drop_index(op.f('ix_team_invite_inviter_id'), table_name='team_invite') - op.drop_index(op.f('ix_team_invite_email'), table_name='team_invite') - op.drop_table('team_invite') - op.drop_index(op.f('ix_domain_project_id'), table_name='domain') - op.drop_index(op.f('ix_domain_hostname'), table_name='domain') - op.drop_table('domain') - op.drop_index(op.f('ix_deployment_repo_id'), table_name='deployment') - op.drop_index(op.f('ix_deployment_repo_full_name'), table_name='deployment') - op.drop_index(op.f('ix_deployment_project_id'), table_name='deployment') - op.drop_index(op.f('ix_deployment_created_at'), table_name='deployment') - op.drop_index(op.f('ix_deployment_concluded_at'), table_name='deployment') - op.drop_index(op.f('ix_deployment_commit_sha'), table_name='deployment') - op.drop_index(op.f('ix_deployment_branch'), table_name='deployment') - op.drop_table('deployment') - op.drop_index(op.f('ix_user_username'), table_name='user') - op.drop_index(op.f('ix_user_updated_at'), table_name='user') - op.drop_index(op.f('ix_user_name'), table_name='user') - op.drop_index(op.f('ix_user_email'), table_name='user') - op.drop_index(op.f('ix_user_created_at'), table_name='user') - op.drop_table('user') - op.drop_index(op.f('ix_project_updated_at'), table_name='project') - op.drop_index(op.f('ix_project_team_id'), table_name='project') - op.drop_index(op.f('ix_project_repo_id'), table_name='project') - op.drop_index(op.f('ix_project_repo_full_name'), table_name='project') - op.drop_index(op.f('ix_project_name'), table_name='project') - op.drop_index(op.f('ix_project_github_installation_id'), table_name='project') - op.drop_index(op.f('ix_project_created_at'), table_name='project') - op.drop_table('project') - op.drop_index(op.f('ix_team_updated_at'), table_name='team') - op.drop_index(op.f('ix_team_name'), table_name='team') - op.drop_index(op.f('ix_team_created_at'), table_name='team') - op.drop_table('team') - op.drop_table('github_installation') + op.drop_index(op.f("ix_alias_updated_at"), table_name="alias") + op.drop_index(op.f("ix_alias_previous_deployment_id"), table_name="alias") + op.drop_index(op.f("ix_alias_deployment_id"), table_name="alias") + op.drop_table("alias") + op.drop_index(op.f("ix_user_identity_user_id"), table_name="user_identity") + op.drop_index(op.f("ix_user_identity_provider_user_id"), table_name="user_identity") + op.drop_index(op.f("ix_user_identity_provider"), table_name="user_identity") + op.drop_table("user_identity") + op.drop_index(op.f("ix_team_member_user_id"), table_name="team_member") + op.drop_index(op.f("ix_team_member_team_id"), table_name="team_member") + op.drop_table("team_member") + op.drop_index(op.f("ix_team_invite_team_id"), table_name="team_invite") + op.drop_index(op.f("ix_team_invite_inviter_id"), table_name="team_invite") + op.drop_index(op.f("ix_team_invite_email"), table_name="team_invite") + op.drop_table("team_invite") + op.drop_index(op.f("ix_domain_project_id"), table_name="domain") + op.drop_index(op.f("ix_domain_hostname"), table_name="domain") + op.drop_table("domain") + op.drop_index(op.f("ix_deployment_repo_id"), table_name="deployment") + op.drop_index(op.f("ix_deployment_repo_full_name"), table_name="deployment") + op.drop_index(op.f("ix_deployment_project_id"), table_name="deployment") + op.drop_index(op.f("ix_deployment_created_at"), table_name="deployment") + op.drop_index(op.f("ix_deployment_concluded_at"), table_name="deployment") + op.drop_index(op.f("ix_deployment_commit_sha"), table_name="deployment") + op.drop_index(op.f("ix_deployment_branch"), table_name="deployment") + op.drop_table("deployment") + op.drop_index(op.f("ix_user_username"), table_name="user") + op.drop_index(op.f("ix_user_updated_at"), table_name="user") + op.drop_index(op.f("ix_user_name"), table_name="user") + op.drop_index(op.f("ix_user_email"), table_name="user") + op.drop_index(op.f("ix_user_created_at"), table_name="user") + op.drop_table("user") + op.drop_index(op.f("ix_project_updated_at"), table_name="project") + op.drop_index(op.f("ix_project_team_id"), table_name="project") + op.drop_index(op.f("ix_project_repo_id"), table_name="project") + op.drop_index(op.f("ix_project_repo_full_name"), table_name="project") + op.drop_index(op.f("ix_project_name"), table_name="project") + op.drop_index(op.f("ix_project_github_installation_id"), table_name="project") + op.drop_index(op.f("ix_project_created_at"), table_name="project") + op.drop_table("project") + op.drop_index(op.f("ix_team_updated_at"), table_name="team") + op.drop_index(op.f("ix_team_name"), table_name="team") + op.drop_index(op.f("ix_team_created_at"), table_name="team") + op.drop_table("team") + op.drop_table("github_installation") # ### end Alembic commands ### diff --git a/app/migrations/versions/87a893d57c86_allowlist.py b/app/migrations/versions/87a893d57c86_allowlist.py index 3182559..ecac3b0 100644 --- a/app/migrations/versions/87a893d57c86_allowlist.py +++ b/app/migrations/versions/87a893d57c86_allowlist.py @@ -1,10 +1,11 @@ -"""allowlist +"""Add allowlist table Revision ID: 87a893d57c86 Revises: 454328a03102 Create Date: 2025-11-13 04:44:28.018622 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ # revision identifiers, used by Alembic. -revision: str = '87a893d57c86' -down_revision: Union[str, Sequence[str], None] = '454328a03102' +revision: str = "87a893d57c86" +down_revision: Union[str, Sequence[str], None] = "454328a03102" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,33 +22,66 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('allowlist', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('type', sa.Enum('email', 'domain', 'pattern', name='allowlist_type'), nullable=False), - sa.Column('value', sa.String(length=255), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "allowlist", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "type", + sa.Enum("email", "domain", "pattern", name="allowlist_type"), + nullable=False, + ), + sa.Column("value", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_allowlist_created_at"), "allowlist", ["created_at"], unique=False + ) + op.create_index(op.f("ix_allowlist_type"), "allowlist", ["type"], unique=False) + op.create_index( + op.f("ix_allowlist_updated_at"), "allowlist", ["updated_at"], unique=False + ) + op.create_index(op.f("ix_allowlist_value"), "allowlist", ["value"], unique=False) + op.create_foreign_key( + None, + "deployment", + "user", + ["created_by_user_id"], + ["id"], + ondelete="SET NULL", + use_alter=True, + ) + op.create_foreign_key( + None, + "project", + "user", + ["created_by_user_id"], + ["id"], + ondelete="SET NULL", + use_alter=True, + ) + op.create_foreign_key( + None, + "team", + "user", + ["created_by_user_id"], + ["id"], + ondelete="SET NULL", + use_alter=True, ) - op.create_index(op.f('ix_allowlist_created_at'), 'allowlist', ['created_at'], unique=False) - op.create_index(op.f('ix_allowlist_type'), 'allowlist', ['type'], unique=False) - op.create_index(op.f('ix_allowlist_updated_at'), 'allowlist', ['updated_at'], unique=False) - op.create_index(op.f('ix_allowlist_value'), 'allowlist', ['value'], unique=False) - op.create_foreign_key(None, 'deployment', 'user', ['created_by_user_id'], ['id'], ondelete='SET NULL', use_alter=True) - op.create_foreign_key(None, 'project', 'user', ['created_by_user_id'], ['id'], ondelete='SET NULL', use_alter=True) - op.create_foreign_key(None, 'team', 'user', ['created_by_user_id'], ['id'], ondelete='SET NULL', use_alter=True) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'team', type_='foreignkey') - op.drop_constraint(None, 'project', type_='foreignkey') - op.drop_constraint(None, 'deployment', type_='foreignkey') - op.drop_index(op.f('ix_allowlist_value'), table_name='allowlist') - op.drop_index(op.f('ix_allowlist_updated_at'), table_name='allowlist') - op.drop_index(op.f('ix_allowlist_type'), table_name='allowlist') - op.drop_index(op.f('ix_allowlist_created_at'), table_name='allowlist') - op.drop_table('allowlist') + op.drop_constraint(None, "team", type_="foreignkey") + op.drop_constraint(None, "project", type_="foreignkey") + op.drop_constraint(None, "deployment", type_="foreignkey") + op.drop_index(op.f("ix_allowlist_value"), table_name="allowlist") + op.drop_index(op.f("ix_allowlist_updated_at"), table_name="allowlist") + op.drop_index(op.f("ix_allowlist_type"), table_name="allowlist") + op.drop_index(op.f("ix_allowlist_created_at"), table_name="allowlist") + op.drop_table("allowlist") # ### end Alembic commands ### diff --git a/app/migrations/versions/b6bd1dff4015_storage.py b/app/migrations/versions/b6bd1dff4015_storage.py new file mode 100644 index 0000000..ad2ef81 --- /dev/null +++ b/app/migrations/versions/b6bd1dff4015_storage.py @@ -0,0 +1,77 @@ +"""Storage + +Revision ID: b6bd1dff4015 +Revises: 31fe53421216 +Create Date: 2026-01-09 02:35:35.553275 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'b6bd1dff4015' +down_revision: Union[str, Sequence[str], None] = '31fe53421216' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('storage', + sa.Column('id', sa.String(length=32), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('type', sa.Enum('database', 'volume', 'kv', 'queue', name='storage_type'), nullable=False), + sa.Column('status', sa.Enum('pending', 'active', 'deleted', name='storage_status'), nullable=False), + sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('error', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('created_by_user_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('team_id', sa.String(length=32), nullable=False), + sa.ForeignKeyConstraint(['created_by_user_id'], ['user.id'], ondelete='SET NULL', use_alter=True), + sa.ForeignKeyConstraint(['team_id'], ['team.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('team_id', 'name', name='uq_storage_team_name') + ) + op.create_index(op.f('ix_storage_created_at'), 'storage', ['created_at'], unique=False) + op.create_index(op.f('ix_storage_name'), 'storage', ['name'], unique=False) + op.create_index(op.f('ix_storage_team_id'), 'storage', ['team_id'], unique=False) + op.create_index('ix_storage_team_name_lower', 'storage', ['team_id', sa.literal_column('lower(name)')], unique=True) + op.create_index(op.f('ix_storage_updated_at'), 'storage', ['updated_at'], unique=False) + op.create_table('storage_project', + sa.Column('id', sa.String(length=32), nullable=False), + sa.Column('storage_id', sa.String(length=32), nullable=False), + sa.Column('project_id', sa.String(length=32), nullable=False), + sa.Column('environment_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('secrets', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ), + sa.ForeignKeyConstraint(['storage_id'], ['storage.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('storage_id', 'project_id', name='uq_storage_project') + ) + op.create_index(op.f('ix_storage_project_project_id'), 'storage_project', ['project_id'], unique=False) + op.create_index(op.f('ix_storage_project_storage_id'), 'storage_project', ['storage_id'], unique=False) + op.create_index('ix_project_team_name_lower', 'project', ['team_id', sa.literal_column('lower(name)')], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_project_team_name_lower', table_name='project') + op.drop_index(op.f('ix_storage_project_storage_id'), table_name='storage_project') + op.drop_index(op.f('ix_storage_project_project_id'), table_name='storage_project') + op.drop_table('storage_project') + op.drop_index(op.f('ix_storage_updated_at'), table_name='storage') + op.drop_index('ix_storage_team_name_lower', table_name='storage') + op.drop_index(op.f('ix_storage_team_id'), table_name='storage') + op.drop_index(op.f('ix_storage_name'), table_name='storage') + op.drop_index(op.f('ix_storage_created_at'), table_name='storage') + op.drop_table('storage') + # ### end Alembic commands ### diff --git a/app/models.py b/app/models.py index 65eec17..c7975da 100644 --- a/app/models.py +++ b/app/models.py @@ -8,11 +8,13 @@ Text, ForeignKey, UniqueConstraint, + Index, event, select, update, func, ) +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime, timezone, timedelta @@ -189,6 +191,7 @@ class Team(Base): # Relationships projects: Mapped[list["Project"]] = relationship(back_populates="team") + storages: Mapped[list["Storage"]] = relationship(back_populates="team") created_by_user: Mapped[User | None] = relationship( foreign_keys=[created_by_user_id] ) @@ -360,8 +363,19 @@ class Project(Base): foreign_keys=[created_by_user_id] ) domains: Mapped[list["Domain"]] = relationship(back_populates="project") + storage_links: Mapped[list["StorageProject"]] = relationship( + back_populates="project" + ) - __table_args__ = (UniqueConstraint("team_id", "name", name="uq_project_team_name"),) + __table_args__ = ( + UniqueConstraint("team_id", "name", name="uq_project_team_name"), + Index( + "ix_project_team_name_lower", + "team_id", + func.lower(name), + unique=True, + ), + ) @property def env_vars(self) -> list[dict[str, str]]: @@ -520,6 +534,14 @@ def get_environment_by_slug( environments = self.active_environments if active_only else self.environments return next((env for env in environments if env["slug"] == slug), None) + @property + def storages(self) -> list["Storage"]: + return [ + link.storage + for link in self.storage_links + if link.storage and link.storage.type == "database" + ] + async def get_domain_by_id(self, db: AsyncSession, domain_id: int) -> dict | None: """Get domain by ID""" result = await db.execute( @@ -606,6 +628,107 @@ def set_project_slug(mapper, connection, project): project.slug = new_slug +class Storage(Base): + __tablename__: str = "storage" + + id: Mapped[str] = mapped_column( + String(32), primary_key=True, default=lambda: token_hex(16) + ) + name: Mapped[str] = mapped_column(String(100), index=True) + type: Mapped[str] = mapped_column( + SQLAEnum("database", "volume", "kv", "queue", name="storage_type"), + nullable=False, + ) + status: Mapped[str] = mapped_column( + SQLAEnum("pending", "active", "deleted", name="storage_status"), + nullable=False, + default="pending", + ) + config: Mapped[dict[str, object]] = mapped_column( + JSONB, nullable=False, default=dict + ) + error: Mapped[dict[str, object] | None] = mapped_column(JSONB, nullable=True) + created_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", use_alter=True, ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + index=True, nullable=False, default=utc_now + ) + updated_at: Mapped[datetime] = mapped_column( + index=True, nullable=False, default=utc_now, onupdate=utc_now + ) + team_id: Mapped[str] = mapped_column(ForeignKey("team.id"), index=True) + + # Relationships + team: Mapped["Team"] = relationship(back_populates="storages") + created_by_user: Mapped[User | None] = relationship( + foreign_keys=[created_by_user_id] + ) + project_links: Mapped[list["StorageProject"]] = relationship( + back_populates="storage" + ) + + __table_args__ = ( + UniqueConstraint("team_id", "name", name="uq_storage_team_name"), + Index( + "ix_storage_team_name_lower", + "team_id", + func.lower(name), + unique=True, + ), + ) + + @override + def __repr__(self): + return f"" + + @property + def projects(self) -> list["Project"]: + return [link.project for link in self.project_links if link.project] + + @property + def color(self) -> str: + match self.type: + case "database": + return "sky" + case "volume": + return "amber" + case "kv": + return "rose" + case "queue": + return "green" + + +class StorageProject(Base): + __tablename__: str = "storage_project" + + id: Mapped[str] = mapped_column( + String(32), primary_key=True, default=lambda: token_hex(16) + ) + storage_id: Mapped[str] = mapped_column(ForeignKey("storage.id"), index=True) + project_id: Mapped[str] = mapped_column(ForeignKey("project.id"), index=True) + environment_ids: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True) + secrets: Mapped[dict[str, object]] = mapped_column( + JSONB, nullable=False, default=dict + ) + created_at: Mapped[datetime] = mapped_column(default=utc_now) + updated_at: Mapped[datetime] = mapped_column( + nullable=False, default=utc_now, onupdate=utc_now + ) + + # Relationships + project: Mapped["Project"] = relationship(back_populates="storage_links") + storage: Mapped["Storage"] = relationship(back_populates="project_links") + + __table_args__ = ( + UniqueConstraint( + "storage_id", + "project_id", + name="uq_storage_project", + ), + ) + + class Deployment(Base): __tablename__: str = "deployment" diff --git a/app/package.json b/app/package.json index 58d515c..7e7e2c6 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "scripts": { "build": "npm run css && npm run email && npm run alpine && npm run htmx && npm run htmx-sse && npm run basecoat", - "css:dev": "tailwindcss --silent -i ./src/styles.css -o ./assets/styles.css --watch", + "css:dev": "tailwindcss -i ./src/styles.css -o ./assets/styles.css --watch", "css": "tailwindcss -i ./src/styles.css -o ./assets/styles.css --minify", "email": "mjml src/login.mjml -o ./templates/email/login.html && mjml src/email-change.mjml -o ./templates/email/email-change.html && mjml src/team-invite.mjml -o ./templates/email/team-invite.html", "alpine": "cp node_modules/alpinejs/dist/cdn.min.js ./assets/alpine.min.js", @@ -10,12 +10,12 @@ "basecoat": "cp node_modules/basecoat-css/dist/js/all.min.js ./assets/basecoat.min.js" }, "devDependencies": { - "@tailwindcss/cli": "^4.1.11", - "alpinejs": "^3.14.9", - "basecoat-css": "^0.3.9", - "htmx-ext-sse": "^2.2.3", - "htmx.org": "^2.0.6", - "mjml": "^4.15.3", - "tailwindcss": "^4.1.11" + "@tailwindcss/cli": "^4.1.18", + "alpinejs": "^3.15.3", + "basecoat-css": "^0.3.10", + "htmx-ext-sse": "^2.2.4", + "htmx.org": "^2.0.8", + "mjml": "^4.18.0", + "tailwindcss": "^4.1.18" } } diff --git a/app/routers/admin.py b/app/routers/admin.py index 83ef2b7..e11df72 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -333,7 +333,7 @@ async def admin_settings( target_user.status = "deleted" await db.commit() - await job_queue.enqueue_job("cleanup_user", target_user.id) + await job_queue.enqueue_job("delete_user", target_user.id) flash( request, diff --git a/app/routers/project.py b/app/routers/project.py index 5ef2062..cf5b11e 100644 --- a/app/routers/project.py +++ b/app/routers/project.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Request, Query +from fastapi import APIRouter, Depends, Request, Query, HTTPException import httpx from fastapi.responses import Response, RedirectResponse from sqlalchemy import select, update @@ -10,6 +10,7 @@ from urllib.parse import urlparse, parse_qs import logging import os +import asyncio from typing import Any from dependencies import ( @@ -35,30 +36,38 @@ User, Team, TeamMember, + Storage, + StorageProject, utc_now, ) from forms.project import ( - NewProjectForm, + ProjectCreateForm, ProjectDeployForm, ProjectDeleteForm, ProjectGeneralForm, ProjectEnvVarsForm, ProjectEnvironmentForm, - ProjectDeleteEnvironmentForm, - ProjectBuildAndProjectDeployForm, - ProjectCancelDeploymentForm, - ProjectRollbackDeploymentForm, + ProjectEnvironmentRemoveForm, + ProjectBuildAndDeployForm, + ProjectDeploymentCancelForm, + ProjectDeploymentRollbackForm, ProjectDomainForm, - ProjectRemoveDomainForm, - ProjectVerifyDomainForm, + ProjectDomainRemoveForm, + ProjectDomainVerifyForm, ProjectResourcesForm, ) +from forms.storage import ( + StorageCreateForm, + StorageProjectForm, + StorageProjectRemoveForm, +) from config import get_settings, Settings from db import get_db from services.github import GitHubService from services.github_installation import GitHubInstallationService from services.deployment import DeploymentService from services.domain import DomainService +from services.preset_detector import PresetDetector from utils.project import get_latest_projects, get_latest_deployments from utils.team import get_latest_teams from utils.pagination import paginate @@ -102,6 +111,7 @@ async def new_project_details( repo_owner: str = Query(None), repo_name: str = Query(None), repo_default_branch: str = Query(None), + fragment: str = Query(None), current_user: User = Depends(get_current_user), team_and_membership: tuple[Team, TeamMember] = Depends(get_team_by_slug), settings: Settings = Depends(get_settings), @@ -119,13 +129,80 @@ async def new_project_details( request.url_for("new_project", team_slug=team.slug), status_code=303 ) - form: Any = await NewProjectForm.from_formdata(request, db=db, team=team) + form: Any = await ProjectCreateForm.from_formdata(request, db=db, team=team) if request.method == "GET": form.repo_id.data = int(repo_id) form.name.data = repo_name form.production_branch.data = repo_default_branch + # Handle build_and_deploy fragment with framework detection + if fragment == "build_and_deploy": + try: + github_oauth_token = await get_user_github_token(db, current_user) + if github_oauth_token: + detector = PresetDetector(settings.presets) + + # Run detection with 5 second timeout + detection = await asyncio.wait_for( + detector.detect_with_commands( + github_service, + github_oauth_token, + int(repo_id), + repo_default_branch, + ), + timeout=5.0, + ) + + # If preset detected, get preset config and set all form fields + if detection["preset"]: + preset_config = next( + ( + p + for p in settings.presets + if p["slug"] == detection["preset"] + ), + None, + ) + + if preset_config: + form.preset.data = detection["preset"] + form.image.data = preset_config.get("image") + if preset_config.get("root_directory"): + form.root_directory.data = preset_config.get( + "root_directory" + ) + form.build_command.data = detection.get( + "build_command" + ) or preset_config.get("build_command") + form.start_command.data = detection.get( + "start_command" + ) or preset_config.get("start_command") + form.pre_deploy_command.data = preset_config.get( + "pre_deploy_command" + ) + + except asyncio.TimeoutError: + logger.warning(f"Framework detection timed out for repo {repo_id}") + flash(request, _("Framework detection timed out."), "error") + except Exception as e: + logger.warning(f"Framework detection failed for repo {repo_id}: {e}") + flash(request, _("Framework detection failed."), "error") + + return TemplateResponse( + request=request, + name="project/partials/_form-build-and-deploy.html", + context={ + "current_user": current_user, + "team": team, + "form": form, + "repo_full_name": f"{repo_owner or ''}/{repo_name or ''}", + "presets": settings.presets, + "images": settings.images, + "detecting": False, + }, + ) + if request.method == "POST" and await form.validate_on_submit(): try: github_oauth_token = await get_user_github_token(db, current_user) @@ -436,6 +513,373 @@ async def project_deployments( ) +@router.api_route( + "/{team_slug}/projects/{project_name}/storage", + methods=["GET", "POST"], + name="project_storage", +) +async def project_storage( + request: Request, + fragment: str | None = Query(None), + project: Project = Depends(get_project_by_name), + current_user: User = Depends(get_current_user), + role: str = Depends(get_role), + team_and_membership: tuple[Team, TeamMember] = Depends(get_team_by_slug), + job_queue: ArqRedis = Depends(get_job_queue), + db: AsyncSession = Depends(get_db), +): + team, membership = team_and_membership + project_name = project.name + + is_admin = get_access(role, "admin") + + associations_query = ( + select(StorageProject) + .join(Storage) + .where( + StorageProject.project_id == project.id, + Storage.type.in_(["database", "volume"]), + Storage.status != "deleted", + ) + .options( + selectinload(StorageProject.storage), + selectinload(StorageProject.project), + ) + .order_by(Storage.name.asc()) + ) + if not is_admin: + associations_query = associations_query.where( + Storage.created_by_user_id == current_user.id + ) + associations_result = await db.execute(associations_query) + associations = associations_result.scalars().all() + + storages_query = ( + select(Storage) + .where( + Storage.team_id == team.id, + Storage.type.in_(["database", "volume"]), + Storage.status != "deleted", + ) + .order_by(Storage.name.asc()) + ) + if not is_admin: + storages_query = storages_query.where( + Storage.created_by_user_id == current_user.id + ) + storages_result = await db.execute(storages_query) + storages = storages_result.scalars().all() + available_storages = [ + storage + for storage in storages + if storage.id not in {association.storage_id for association in associations} + ] + + create_storage_form: Any = await StorageCreateForm.from_formdata( + request, db=db, team=team, project=project + ) + if create_storage_form.environment_ids.data in (None, ""): + create_storage_form.environment_ids.data = [] + + connect_storage_form: Any = await StorageProjectForm.from_formdata( + request, + storages=available_storages, + projects=[project], + associations=associations, + ) + if connect_storage_form.environment_ids.data in (None, ""): + connect_storage_form.environment_ids.data = [] + + edit_storage_form: Any = await StorageProjectForm.from_formdata( + request, + storages=storages, + projects=[project], + associations=associations, + ) + if edit_storage_form.environment_ids.data in (None, ""): + edit_storage_form.environment_ids.data = [] + + remove_storage_form: Any = await StorageProjectRemoveForm.from_formdata( + request, + associations=associations, + ) + + if request.method == "POST" and fragment == "create_storage": + if await create_storage_form.validate_on_submit(): + try: + storage = Storage( + name=create_storage_form.name.data, + type=create_storage_form.type.data, + status="pending", + team_id=team.id, + created_by_user_id=current_user.id, + ) + db.add(storage) + await db.flush() + association = StorageProject( + project_id=project.id, + storage_id=storage.id, + environment_ids=create_storage_form.environment_ids.data or [], + ) + db.add(association) + await db.commit() + try: + await job_queue.enqueue_job("provision_storage", storage.id) + except Exception as exc: + logger.error( + "Failed to enqueue provisioning for storage %s: %s", + storage.id, + exc, + ) + flash(request, _("Storage created and connected."), "success") + return RedirectResponseX( + url=str( + request.url_for( + "project_storage", + team_slug=team.slug, + project_name=project.name, + ) + ), + request=request, + ) + except Exception as e: + await db.rollback() + logger.error( + f"Error creating storage for project {project_name}: {str(e)}" + ) + flash(request, _("Error creating storage."), "error") + + if request.headers.get("HX-Request"): + return TemplateResponse( + request=request, + name="project/partials/_dialog-project-storage-form.html", + context={ + "current_user": current_user, + "role": role, + "team": team, + "project": project, + "form": create_storage_form, + "storages": None, + "fragment": "create_storage", + }, + ) + + if request.method == "POST" and fragment == "connect_storage": + if await connect_storage_form.validate_on_submit(): + try: + existing_result = await db.execute( + select(StorageProject).where( + StorageProject.project_id == project.id, + StorageProject.storage_id + == connect_storage_form.storage_id.data, + ) + ) + existing_association = existing_result.scalar_one_or_none() + if existing_association: + existing_association.environment_ids = ( + connect_storage_form.environment_ids.data or [] + ) + flash(request, _("Storage connection updated."), "success") + else: + association = StorageProject( + project_id=project.id, + storage_id=connect_storage_form.storage_id.data, + environment_ids=connect_storage_form.environment_ids.data or [], + ) + db.add(association) + flash(request, _("Storage connected."), "success") + await db.commit() + return RedirectResponseX( + url=str( + request.url_for( + "project_storage", + team_slug=team.slug, + project_name=project.name, + ) + ), + request=request, + ) + except Exception as e: + await db.rollback() + logger.error( + f"Error connecting storage for project {project_name}: {str(e)}" + ) + flash(request, _("Error connecting storage."), "error") + if request.headers.get("HX-Request"): + return TemplateResponse( + request=request, + name="project/partials/_dialog-project-storage-form.html", + context={ + "current_user": current_user, + "role": role, + "team": team, + "project": project, + "form": connect_storage_form, + "storages": available_storages, + "fragment": "connect_storage", + }, + ) + + if request.method == "POST" and fragment == "update_storage_env": + if await edit_storage_form.validate_on_submit(): + association_id = edit_storage_form.association_id.data + association_by_id = { + str(association.id): association for association in associations + } + association = ( + association_by_id.get(str(association_id)) if association_id else None + ) + if not association: + flash(request, _("Association not found."), "error") + else: + association.environment_ids = ( + edit_storage_form.environment_ids.data or [] + ) + await db.commit() + flash(request, _("Storage connection updated."), "success") + return RedirectResponseX( + url=str( + request.url_for( + "project_storage", + team_slug=team.slug, + project_name=project.name, + ) + ), + request=request, + ) + if request.headers.get("HX-Request"): + association = None + association_id = edit_storage_form.association_id.data + if association_id: + association = next( + ( + item + for item in associations + if str(item.id) == str(association_id) + ), + None, + ) + return TemplateResponse( + request=request, + name="project/partials/_dialog-project-storage-environments-form.html", + context={ + "team": team, + "project": project, + "association": association, + "edit_storage_form": edit_storage_form, + }, + ) + + if request.method == "POST" and fragment == "disconnect_storage": + if await remove_storage_form.validate_on_submit(): + association = remove_storage_form.association + await db.delete(association) + await db.commit() + flash(request, _("Storage disconnected."), "success") + return RedirectResponseX( + url=str( + request.url_for( + "project_storage", + team_slug=team.slug, + project_name=project.name, + ) + ), + request=request, + ) + if request.headers.get("HX-Request"): + return TemplateResponse( + request=request, + name="project/pages/storage.html", + context={ + "current_user": current_user, + "role": role, + "team": team, + "project": project, + "associations": associations, + "storages": storages, + "available_storages": available_storages, + "create_storage_form": create_storage_form, + "connect_storage_form": connect_storage_form, + "edit_storage_form": edit_storage_form, + "remove_storage_form": remove_storage_form, + }, + ) + + latest_teams = await get_latest_teams( + db=db, current_user=current_user, current_team=team + ) + latest_projects = await get_latest_projects( + db=db, team=team, current_project=project + ) + + return TemplateResponse( + request=request, + name="project/pages/storage.html", + context={ + "current_user": current_user, + "role": role, + "team": team, + "project": project, + "associations": associations, + "storages": storages, + "available_storages": available_storages, + "create_storage_form": create_storage_form, + "connect_storage_form": connect_storage_form, + "edit_storage_form": edit_storage_form, + "remove_storage_form": remove_storage_form, + "latest_projects": latest_projects, + "latest_teams": latest_teams, + }, + ) + + +@router.get( + "/{team_slug}/projects/{project_name}/storage/{storage_id}/status", + name="project_storage_status", +) +async def project_storage_status( + request: Request, + storage_id: str, + project: Project = Depends(get_project_by_name), + current_user: User = Depends(get_current_user), + role: str = Depends(get_role), + team_and_membership: tuple[Team, TeamMember] = Depends(get_team_by_slug), + db: AsyncSession = Depends(get_db), +): + team, membership = team_and_membership + is_admin = get_access(role, "admin") + + query = ( + select(Storage) + .join(StorageProject) + .where( + Storage.id == storage_id, + StorageProject.project_id == project.id, + Storage.team_id == team.id, + Storage.status != "deleted", + ) + ) + if not is_admin: + query = query.where(Storage.created_by_user_id == current_user.id) + + result = await db.execute(query) + storage = result.scalar_one_or_none() + if not storage: + raise HTTPException(status_code=404, detail="Storage not found") + + return TemplateResponse( + request=request, + name="project/partials/_storage-status.html", + context={ + "current_user": current_user, + "team": team, + "project": project, + "role": role, + "storage": storage, + }, + ) + + @router.api_route( "/{team_slug}/projects/{project_name}/deploy", methods=["GET", "POST"], @@ -696,7 +1140,7 @@ async def project_cancel( ): team, membership = team_and_membership - form: Any = await ProjectCancelDeploymentForm.from_formdata(request) + form: Any = await ProjectDeploymentCancelForm.from_formdata(request) if request.method == "POST" and await form.validate_on_submit(): try: @@ -757,7 +1201,7 @@ async def project_rollback( ): team, membership = team_and_membership - form: Any = await ProjectRollbackDeploymentForm.from_formdata(request) + form: Any = await ProjectDeploymentRollbackForm.from_formdata(request) if request.method == "POST" and await form.validate_on_submit(): try: @@ -821,7 +1265,7 @@ async def project_rollback( # ): # team, membership = team_and_membership -# form: Any = await ProjectRollbackDeploymentForm.from_formdata(request) +# form: Any = await ProjectDeploymentRollbackForm.from_formdata(request) # if request.method == "POST" and await form.validate_on_submit(): # try: @@ -915,7 +1359,7 @@ async def project_settings( await db.commit() # Project is marked as deleted, actual cleanup is delegated to a job - await job_queue.enqueue_job("cleanup_project", project.id) + await job_queue.enqueue_job("delete_project", project.id) flash( request, @@ -1095,7 +1539,7 @@ async def project_settings( environment_form: Any = await ProjectEnvironmentForm.from_formdata( request=request, project=project ) - delete_environment_form: Any = await ProjectDeleteEnvironmentForm.from_formdata( + remove_environment_form: Any = await ProjectEnvironmentRemoveForm.from_formdata( request=request, project=project ) environments_updated = False @@ -1139,9 +1583,9 @@ async def project_settings( flash(request, _("Something went wrong. Please try again."), "error") if fragment == "delete_environment": - if await delete_environment_form.validate_on_submit(): + if await remove_environment_form.validate_on_submit(): try: - environment_id = delete_environment_form.environment_id.data + environment_id = remove_environment_form.environment_id.data if project.delete_environment(environment_id): domains_result = await db.execute( select(Domain).where( @@ -1185,14 +1629,14 @@ async def project_settings( "team": team, "project": project, "environment_form": environment_form, - "delete_environment_form": delete_environment_form, + "remove_environment_form": remove_environment_form, "colors": COLORS, "updated": environments_updated, }, ) # Build and deploy - build_and_deploy_form: Any = await ProjectBuildAndProjectDeployForm.from_formdata( + build_and_deploy_form: Any = await ProjectBuildAndDeployForm.from_formdata( request, data={ "preset": project.config.get("preset"), @@ -1288,10 +1732,10 @@ async def project_settings( domain_form: Any = await ProjectDomainForm.from_formdata( request=request, project=project, domains=domains, db=db ) - remove_domain_form: Any = await ProjectRemoveDomainForm.from_formdata( + remove_domain_form: Any = await ProjectDomainRemoveForm.from_formdata( request=request, project=project, domains=domains ) - verify_domain_form: Any = await ProjectVerifyDomainForm.from_formdata( + verify_domain_form: Any = await ProjectDomainVerifyForm.from_formdata( request=request, domains=domains ) @@ -1469,7 +1913,7 @@ async def project_settings( "project": project, "general_form": general_form, "environment_form": environment_form, - "delete_environment_form": delete_environment_form, + "remove_environment_form": remove_environment_form, "build_and_deploy_form": build_and_deploy_form, "resources_form": resources_form, "default_cpus": settings.default_cpus, @@ -1516,7 +1960,7 @@ async def project_deployment( cancel_form = None if not deployment.conclusion: - cancel_form: Any = await ProjectCancelDeploymentForm.from_formdata(request) + cancel_form: Any = await ProjectDeploymentCancelForm.from_formdata(request) env_aliases = await project.get_environment_aliases(db=db) diff --git a/app/routers/team.py b/app/routers/team.py index 88d14bf..8333e23 100644 --- a/app/routers/team.py +++ b/app/routers/team.py @@ -1,5 +1,5 @@ import os -from fastapi import APIRouter, Depends, Request, Query +from fastapi import APIRouter, Depends, Request, Query, HTTPException from fastapi.responses import RedirectResponse, Response from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession @@ -11,7 +11,17 @@ from datetime import timedelta import resend -from models import Project, Deployment, User, Team, TeamMember, utc_now, TeamInvite +from models import ( + Project, + Deployment, + User, + Team, + TeamMember, + TeamInvite, + Storage, + StorageProject, + utc_now, +) from dependencies import ( get_current_user, get_team_by_slug, @@ -22,6 +32,8 @@ templates, get_role, get_access, + get_storage_by_name, + RedirectResponseX, ) from config import get_settings, Settings from db import get_db @@ -30,11 +42,17 @@ from forms.team import ( TeamDeleteForm, TeamGeneralForm, - NewTeamForm, - TeamAddMemberForm, - TeamDeleteMemberForm, + TeamCreateForm, + TeamMemberAddForm, + TeamMemberRemoveForm, TeamMemberRoleForm, ) +from forms.storage import ( + StorageCreateForm, + StorageDeleteForm, + StorageProjectForm, + StorageProjectRemoveForm, +) logger = logging.getLogger(__name__) @@ -47,7 +65,7 @@ async def new_team( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - form: Any = await NewTeamForm.from_formdata(request) + form: Any = await TeamCreateForm.from_formdata(request) if request.method == "POST" and await form.validate_on_submit(): team = Team(name=form.name.data, created_by_user_id=current_user.id) @@ -64,7 +82,7 @@ async def new_team( return TemplateResponse( request=request, - name="team/partials/_dialog-new-team.html", + name="team/partials/_dialog-new-team-form.html", context={"form": form}, ) @@ -126,6 +144,10 @@ async def team_projects( ): team, membership = team_and_membership + latest_teams = await get_latest_teams( + db=db, current_user=current_user, current_team=team + ) + per_page = 25 query = ( @@ -136,24 +158,533 @@ async def team_projects( pagination = await paginate(db, query, page, per_page) + return TemplateResponse( + request=request, + name="team/pages/projects.html", + context={ + "current_user": current_user, + "team": team, + "role": role, + "latest_teams": latest_teams, + "projects": pagination.get("items"), + "pagination": pagination, + }, + ) + + +@router.api_route("/{team_slug}/storage", methods=["GET", "POST"], name="team_storage") +async def team_storage( + request: Request, + page: int = Query(1, ge=1), + storage_search: str | None = Query(None), + storage_type: str | None = Query(None), + fragment: str | None = Query(None), + current_user: User = Depends(get_current_user), + role: str = Depends(get_role), + team_and_membership: tuple[Team, TeamMember] = Depends(get_team_by_slug), + job_queue: ArqRedis = Depends(get_job_queue), + db: AsyncSession = Depends(get_db), +): + team, membership = team_and_membership + + form: Any = await StorageCreateForm.from_formdata(request, db=db, team=team) + + if request.method == "POST": + if await form.validate_on_submit(): + storage = Storage( + name=form.name.data, + type=form.type.data, + status="pending", + team_id=team.id, + created_by_user_id=current_user.id, + ) + db.add(storage) + await db.commit() + try: + await job_queue.enqueue_job("provision_storage", storage.id) + except Exception as exc: + logger.error( + "Failed to enqueue provisioning for storage %s: %s", + storage.id, + exc, + ) + flash(request, _("Storage created."), "success") + + return RedirectResponseX( + request.url_for("team_storage", team_slug=team.slug), + status_code=200, + request=request, + ) + + return TemplateResponse( + request=request, + name="team/partials/_dialog-new-storage-form.html", + context={ + "team": team, + "form": form, + }, + ) + latest_teams = await get_latest_teams( db=db, current_user=current_user, current_team=team ) + per_page = 25 + + allowed_types = {"database", "volume", "kv", "queue"} + storage_type = storage_type if storage_type in allowed_types else None + + query = select(Storage).where( + Storage.team_id == team.id, + Storage.status != "deleted", + ) + if not get_access(role, "admin"): + query = query.where(Storage.created_by_user_id == current_user.id) + if storage_type and storage_type != "all": + query = query.where(Storage.type == storage_type) + if storage_search: + query = query.where(Storage.name.ilike(f"%{storage_search}%")) + + query = query.options( + selectinload(Storage.project_links).selectinload(StorageProject.project) + ).order_by(Storage.updated_at.desc()) + + pagination = await paginate(db, query, page, per_page) + + projects = await db.execute( + select(Project) + .where(Project.team_id == team.id, Project.status != "deleted") + .order_by(Project.name.asc()) + ) + projects = projects.scalars().all() + + storage_count_query = select(func.count(Storage.id)).where( + Storage.team_id == team.id, + Storage.status != "deleted", + ) + if not get_access(role, "admin"): + storage_count_query = storage_count_query.where( + Storage.created_by_user_id == current_user.id + ) + storage_count_result = await db.execute(storage_count_query) + storage_count = storage_count_result.scalar_one() or 0 + + if request.headers.get("HX-Request") and fragment == "storage-content": + return TemplateResponse( + request=request, + name="team/partials/_storage-list.html", + context={ + "current_user": current_user, + "team": team, + "role": role, + "projects": projects, + "form": form, + "pagination": pagination, + "storages": pagination.get("items"), + "storage_search": storage_search, + "storage_type": storage_type, + "storage_count": storage_count, + }, + ) + return TemplateResponse( request=request, - name="team/pages/projects.html", + name="team/pages/storage.html", context={ "current_user": current_user, "team": team, "role": role, - "projects": pagination.get("items"), + "latest_teams": latest_teams, + "projects": projects, + "form": form, "pagination": pagination, + "storages": pagination.get("items"), + "storage_search": storage_search, + "storage_type": storage_type, + "storage_count": storage_count, + }, + ) + + +@router.api_route( + "/{team_slug}/storage/{storage_name}", + methods=["GET", "POST"], + name="team_storage_item", +) +async def team_storage_item( + request: Request, + fragment: str | None = Query(None), + current_user: User = Depends(get_current_user), + role: str = Depends(get_role), + team_and_membership: tuple[Team, TeamMember] = Depends(get_team_by_slug), + storage: Storage = Depends(get_storage_by_name), + job_queue: ArqRedis = Depends(get_job_queue), + db: AsyncSession = Depends(get_db), +): + team, membership = team_and_membership + + is_admin = get_access(role, "admin") + is_storage_creator = storage.created_by_user_id == current_user.id + if not is_admin and not is_storage_creator: + raise HTTPException(status_code=404, detail="Storage not found") + + delete_form: Any = await StorageDeleteForm.from_formdata(request) + + if request.method == "POST" and fragment == "danger": + if not get_access(role, "admin"): + flash( + request, + _("You don't have permission to delete storage."), + "warning", + ) + elif await delete_form.validate_on_submit(): + storage.status = "deleted" + await db.commit() + if storage.type in ("database", "volume"): + try: + await job_queue.enqueue_job("deprovision_storage", storage.id) + except Exception as exc: + logger.error( + "Failed to enqueue deprovisioning for storage %s: %s", + storage.id, + exc, + ) + flash(request, _("Storage deleted."), "success") + return RedirectResponse( + url=str(request.url_for("team_storage", team_slug=team.slug)), + status_code=303, + ) + + projects_query = ( + select(Project) + .where(Project.team_id == team.id, Project.status != "deleted") + .order_by(Project.name.asc()) + ) + if not is_admin: + projects_query = projects_query.where( + Project.created_by_user_id == current_user.id + ) + projects_result = await db.execute(projects_query) + projects = projects_result.scalars().all() + + associations_query = ( + select(StorageProject) + .join(Project) + .where( + StorageProject.storage_id == storage.id, + Project.team_id == team.id, + Project.status != "deleted", + ) + .options(selectinload(StorageProject.project)) + .order_by(Project.name.asc()) + ) + if not is_admin: + associations_query = associations_query.where( + Project.created_by_user_id == current_user.id + ) + associations_result = await db.execute(associations_query) + associations = associations_result.scalars().all() + available_projects = [ + project + for project in projects + if project.id not in {association.project_id for association in associations} + ] + default_project = available_projects[0] if available_projects else None + + association_form: Any = await StorageProjectForm.from_formdata( + request, storage=storage, projects=projects, associations=associations + ) + remove_association_form: Any = await StorageProjectRemoveForm.from_formdata( + request, associations=associations + ) + + if request.method == "GET" and fragment == "environment_select": + project_id = request.query_params.get("project_id") + selected_project = next( + (project for project in projects if project.id == project_id), None + ) + if not selected_project: + flash( + request, + _("You don't have permission to update storage associations."), + "warning", + ) + return Response(status_code=403) + association_form.project_id.data = project_id + association_form.storage_id.data = storage.id + return TemplateResponse( + request=request, + name="team/partials/_storage-select-environments.html", + context={ + "current_user": current_user, + "team": team, + "role": role, + "storage": storage, + "associations": associations, + "association_form": association_form, + "selected_project": selected_project, + "is_active": False, + }, + ) + + if request.method == "POST" and fragment == "association": + if not is_admin and not is_storage_creator: + flash( + request, + _("You don't have permission to update storage associations."), + "warning", + ) + elif await association_form.validate_on_submit(): + association_id = association_form.association_id.data + association_by_id = { + str(association.id): association for association in associations + } + if association_id: + association = association_by_id.get(str(association_id)) + if association: + association.environment_ids = ( + association_form.environment_ids.data or [] + ) + flash(request, _("Association updated."), "success") + await db.commit() + else: + flash(request, _("Association not found."), "error") + await db.rollback() + elif association_form.association: + association_form.association.environment_ids = ( + association_form.environment_ids.data or [] + ) + flash(request, _("Association updated."), "success") + await db.commit() + else: + existing_result = await db.execute( + select(StorageProject).where( + StorageProject.project_id == association_form.project_id.data, + StorageProject.storage_id == storage.id, + ) + ) + existing_association = existing_result.scalar_one_or_none() + if existing_association: + existing_association.environment_ids = ( + association_form.environment_ids.data or [] + ) + flash(request, _("Association updated."), "success") + else: + association = StorageProject( + project_id=association_form.project_id.data, + storage_id=storage.id, + environment_ids=association_form.environment_ids.data or [], + ) + db.add(association) + flash(request, _("Project linked to storage."), "success") + await db.commit() + associations_result = await db.execute(associations_query) + associations = associations_result.scalars().all() + available_projects = [ + project + for project in projects + if project.id + not in {association.project_id for association in associations} + ] + default_project = available_projects[0] if available_projects else None + association_form = await StorageProjectForm.from_formdata( + request, + storage=storage, + projects=projects, + associations=associations, + ) + remove_association_form = await StorageProjectRemoveForm.from_formdata( + request, + associations=associations, + ) + if request.headers.get("HX-Request"): + return TemplateResponse( + request=request, + name="team/partials/_storage-associations.html", + context={ + "current_user": current_user, + "team": team, + "role": role, + "storage": storage, + "projects": projects, + "associations": associations, + "association_form": association_form, + "remove_association_form": remove_association_form, + "available_projects": available_projects, + "default_project": default_project, + }, + ) + return RedirectResponse( + url=str( + request.url_for( + "team_storage_item", + team_slug=team.slug, + storage_name=storage.name, + ) + ), + status_code=303, + ) + if request.headers.get("HX-Request"): + return TemplateResponse( + request=request, + name="team/partials/_storage-associations.html", + context={ + "current_user": current_user, + "team": team, + "role": role, + "storage": storage, + "projects": projects, + "associations": associations, + "association_form": association_form, + "remove_association_form": remove_association_form, + "available_projects": available_projects, + "default_project": default_project, + }, + ) + + if request.method == "POST" and fragment == "delete_association": + if not is_admin and not is_storage_creator: + flash( + request, + _("You don't have permission to update storage associations."), + "warning", + ) + elif await remove_association_form.validate_on_submit(): + association = remove_association_form.association + await db.delete(association) + await db.commit() + associations_result = await db.execute(associations_query) + associations = associations_result.scalars().all() + available_projects = [ + project + for project in projects + if project.id + not in {association.project_id for association in associations} + ] + default_project = available_projects[0] if available_projects else None + association_form = await StorageProjectForm.from_formdata( + request, + storage=storage, + projects=projects, + associations=associations, + ) + remove_association_form = await StorageProjectRemoveForm.from_formdata( + request, + associations=associations, + ) + flash(request, _("Association removed."), "success") + if request.headers.get("HX-Request"): + return TemplateResponse( + request=request, + name="team/partials/_storage-associations.html", + context={ + "current_user": current_user, + "team": team, + "role": role, + "storage": storage, + "projects": projects, + "associations": associations, + "association_form": association_form, + "remove_association_form": remove_association_form, + "available_projects": available_projects, + "default_project": default_project, + }, + ) + return RedirectResponse( + url=str( + request.url_for( + "team_storage_item", + team_slug=team.slug, + storage_name=storage.name, + ) + ), + status_code=303, + ) + if request.headers.get("HX-Request"): + return TemplateResponse( + request=request, + name="team/partials/_storage-associations.html", + context={ + "current_user": current_user, + "team": team, + "role": role, + "storage": storage, + "projects": projects, + "associations": associations, + "association_form": association_form, + "remove_association_form": remove_association_form, + "available_projects": available_projects, + "default_project": default_project, + }, + ) + + latest_teams = await get_latest_teams( + db=db, current_user=current_user, current_team=team + ) + + return TemplateResponse( + request=request, + name="team/pages/storage-item.html", + context={ + "current_user": current_user, + "team": team, + "role": role, + "storage": storage, + "delete_form": delete_form, + "associations": associations, + "association_form": association_form, + "remove_association_form": remove_association_form, + "projects": projects, + "available_projects": available_projects, + "default_project": default_project, "latest_teams": latest_teams, }, ) +@router.get( + "/{team_slug}/storage/{storage_id}/status", + name="team_storage_status", +) +async def team_storage_status( + request: Request, + storage_id: str, + current_user: User = Depends(get_current_user), + role: str = Depends(get_role), + team_and_membership: tuple[Team, TeamMember] = Depends(get_team_by_slug), + db: AsyncSession = Depends(get_db), +): + team, membership = team_and_membership + is_admin = get_access(role, "admin") + + query = ( + select(Storage) + .where( + Storage.id == storage_id, + Storage.team_id == team.id, + Storage.status != "deleted", + ) + ) + if not is_admin: + query = query.where(Storage.created_by_user_id == current_user.id) + + result = await db.execute(query) + storage = result.scalar_one_or_none() + if not storage: + raise HTTPException(status_code=404, detail="Storage not found") + + return TemplateResponse( + request=request, + name="team/partials/_storage-status.html", + context={ + "current_user": current_user, + "team": team, + "role": role, + "storage": storage, + }, + ) + + @router.api_route( "/{team_slug}/settings", methods=["GET", "POST"], name="team_settings" ) @@ -197,7 +728,7 @@ async def team_settings( await db.commit() # Team is marked as deleted, actual cleanup is delegated to a job - await job_queue.enqueue_job("cleanup_team", team.id) + await job_queue.enqueue_job("delete_team", team.id) flash( request, @@ -312,7 +843,7 @@ async def team_settings( ) # Members - add_member_form: Any = await TeamAddMemberForm.from_formdata( + add_member_form: Any = await TeamMemberAddForm.from_formdata( request, db=db, team=team ) @@ -328,13 +859,13 @@ async def team_settings( await db.commit() _send_member_invite(request, invite, team, current_user, settings) - delete_member_form: Any = await TeamDeleteMemberForm.from_formdata(request) + remove_member_form: Any = await TeamMemberRemoveForm.from_formdata(request) if fragment == "delete_member": - if await delete_member_form.validate_on_submit(): + if await remove_member_form.validate_on_submit(): try: user = await db.scalar( - select(User).where(User.email == delete_member_form.email.data) + select(User).where(User.email == remove_member_form.email.data) ) if not user: flash(request, _("User not found."), "error") @@ -453,7 +984,7 @@ async def team_settings( "members": members, "member_invites": member_invites, "add_member_form": add_member_form, - "delete_member_form": delete_member_form, + "remove_member_form": remove_member_form, "member_role_form": member_role_form, "owner_count": owner_count, }, @@ -474,7 +1005,7 @@ async def team_settings( "general_form": general_form, "members": members, "add_member_form": add_member_form, - "delete_member_form": delete_member_form, + "remove_member_form": remove_member_form, "member_role_form": member_role_form, "member_invites": member_invites, "owner_count": owner_count, diff --git a/app/routers/user.py b/app/routers/user.py index 8de9077..654b15e 100644 --- a/app/routers/user.py +++ b/app/routers/user.py @@ -29,7 +29,7 @@ UserDeleteForm, UserGeneralForm, UserEmailForm, - UserRevokeOAuthAccessForm, + UserOAuthAccessRevokeForm, ) from forms.team import TeamLeaveForm, TeamInviteAcceptForm @@ -57,7 +57,7 @@ async def user_settings( current_user.status = "deleted" await db.commit() - await job_queue.enqueue_job("cleanup_user", current_user.id) + await job_queue.enqueue_job("delete_user", current_user.id) flash( request, @@ -355,7 +355,7 @@ async def user_settings( elif identity.provider == "google" and identity.provider_metadata: google_email = identity.provider_metadata.get("email") - revoke_oauth_access_form: Any = await UserRevokeOAuthAccessForm.from_formdata( + revoke_oauth_access_form: Any = await UserOAuthAccessRevokeForm.from_formdata( request ) diff --git a/app/services/deployment.py b/app/services/deployment.py index c2d4f41..b45b115 100644 --- a/app/services/deployment.py +++ b/app/services/deployment.py @@ -9,7 +9,7 @@ from arq.connections import ArqRedis from arq.jobs import Job -from models import Deployment, Alias, Project, User, Domain +from models import Deployment, Alias, Project, User, Domain, Storage, StorageProject from utils.environment import get_environment_for_branch from config import Settings @@ -67,7 +67,7 @@ def get_alias_domains( return values - def build_runtime_env_vars( + def get_runtime_env_vars( self, deployment: Deployment, settings: Settings ) -> dict[str, str]: """Build runner environment variables for a deployment.""" @@ -125,6 +125,32 @@ def build_runtime_env_vars( return env_vars + async def get_runtime_mounts( + self, deployment: Deployment, db: AsyncSession, settings: Settings + ) -> list[str]: + """Build container bind mounts for storage resources.""" + result = await db.execute( + select(StorageProject, Storage) + .join(Storage, StorageProject.storage_id == Storage.id) + .where( + StorageProject.project_id == deployment.project_id, + Storage.status == "active", + Storage.type.in_(["database", "volume"]), + ) + ) + mounts: list[str] = [] + for association, storage in result.all(): + env_ids = association.environment_ids or [] + if env_ids and deployment.environment_id not in env_ids: + continue + host_base = settings.host_data_dir or settings.data_dir + host_path = os.path.join( + host_base, "storage", storage.team_id, storage.type, storage.name + ) + container_path = f"/data/{storage.type}/{storage.name}" + mounts.append(f"{host_path}:{container_path}") + return mounts + async def setup_aliases( self, deployment: Deployment, db: AsyncSession, settings: Settings ) -> None: @@ -340,7 +366,7 @@ async def create( if not queue: raise ValueError("No job queue provided for deployment creation.") - job = await queue.enqueue_job("deploy_start", deployment.id) + job = await queue.enqueue_job("start_deployment", deployment.id) deployment.job_id = job.job_id await db.commit() diff --git a/app/services/github.py b/app/services/github.py index 92dc61b..09cf9f0 100644 --- a/app/services/github.py +++ b/app/services/github.py @@ -298,3 +298,69 @@ async def get_repository_installation(self, repo_full_name: str) -> dict: ) response.raise_for_status() return response.json() + + async def get_git_tree( + self, + user_access_token: str, + repo_id: int, + sha: str = "HEAD", + recursive: bool = True, + ) -> dict: + """Get git tree for repository (all files/directories). + + Args: + user_access_token: User's GitHub OAuth token + repo_id: Repository ID + sha: Git reference (branch/tag/commit, default: HEAD) + recursive: Get entire tree recursively (default: True) + + Returns: + Tree object with array of file paths + """ + url = f"https://api.github.com/repositories/{repo_id}/git/trees/{sha}" + if recursive: + url += "?recursive=1" + + response = httpx.get( + url, + headers={"Authorization": f"Bearer {user_access_token}"}, + timeout=10.0, + ) + response.raise_for_status() + return response.json() + + async def get_file_content( + self, + user_access_token: str, + repo_id: int, + path: str, + ref: str = "HEAD", + ) -> str | None: + """Get file content from repository. + + Args: + user_access_token: User's GitHub OAuth token + repo_id: Repository ID + path: File path in repository + ref: Git reference (branch/tag/commit, default: HEAD) + + Returns: + Decoded file content as string, or None if file not found + """ + try: + response = httpx.get( + f"https://api.github.com/repositories/{repo_id}/contents/{path}?ref={ref}", + headers={"Authorization": f"Bearer {user_access_token}"}, + timeout=10.0, + ) + response.raise_for_status() + data = response.json() + + # GitHub returns base64 encoded content + import base64 + + return base64.b64decode(data["content"]).decode("utf-8") + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + return None + raise diff --git a/app/services/preset_detector.py b/app/services/preset_detector.py new file mode 100644 index 0000000..d2b156b --- /dev/null +++ b/app/services/preset_detector.py @@ -0,0 +1,222 @@ +"""Preset detection service for automatically identifying project types.""" + +import fnmatch +import json +import logging +from typing import Optional + +from services.github import GitHubService + +logger = logging.getLogger(__name__) + + +class PresetDetector: + """Detect preset from repository files. + + Uses GitHub's git trees API for fast detection (<3 seconds). + Patterns are loaded from presets configuration. + """ + + def __init__(self, presets: list[dict]): + """Initialize detector with presets from settings. + + Args: + presets: List of preset dictionaries from settings.presets + """ + self.patterns = [] + for preset in presets: + detection = preset.get("detection") + if detection: + self.patterns.append( + { + "preset": preset["slug"], + "priority": detection.get("priority", 0), + "any_files": detection.get("any_files", []), + "all_files": detection.get("all_files", []), + "any_paths": detection.get("any_paths", []), + "none_files": detection.get("none_files", []), + "package_check": detection.get("package_check"), + } + ) + + async def detect( + self, + github_service: GitHubService, + user_access_token: str, + repo_id: int, + default_branch: str, + ) -> Optional[str]: + """Detect preset from repository. + + Args: + github_service: GitHubService instance + user_access_token: User's GitHub OAuth token + repo_id: Repository ID + default_branch: Default branch name + + Returns: + Preset slug (e.g., 'django', 'nodejs') or None if no match + """ + if not self.patterns: + logger.warning("No detection patterns configured") + return None + + try: + logger.info(f"Fetching git tree for repo {repo_id}") + tree = await github_service.get_git_tree( + user_access_token, repo_id, sha=default_branch, recursive=True + ) + + paths = { + item["path"] for item in tree.get("tree", []) if item["type"] == "blob" + } + logger.debug(f"Found {len(paths)} files in repository") + + if not paths: + logger.warning("No files found in repository") + return None + + matches = [] + for pattern in self.patterns: + if await self._matches_pattern( + paths, + pattern, + github_service, + user_access_token, + repo_id, + default_branch, + ): + matches.append(pattern) + logger.debug(f"Pattern matched: {pattern['preset']}") + + if not matches: + logger.info("No preset patterns matched") + return None + + matches.sort(key=lambda p: p["priority"], reverse=True) + best_match = matches[0] + + logger.info(f"Detected preset: {best_match['preset']}") + return best_match["preset"] + + except Exception as e: + logger.exception(f"Preset detection failed: {e}") + return None + + async def _matches_pattern( + self, + paths: set[str], + pattern: dict, + github_service: GitHubService, + user_access_token: str, + repo_id: int, + default_branch: str, + ) -> bool: + """Check if paths match a detection pattern.""" + if pattern.get("any_files"): + if not any(self._path_matches(paths, p) for p in pattern["any_files"]): + return False + + if pattern.get("all_files"): + if not all(self._path_matches(paths, p) for p in pattern["all_files"]): + return False + + if pattern.get("any_paths"): + if not any(self._path_matches(paths, p) for p in pattern["any_paths"]): + return False + + if pattern.get("none_files"): + if any(self._path_matches(paths, p) for p in pattern["none_files"]): + return False + + pkg_check = pattern.get("package_check") + if pkg_check: + found = False + + py_files = [ + p for p in paths if p.endswith(("requirements.txt", "pyproject.toml")) + ] + for py_file in py_files: + try: + content = await github_service.get_file_content( + user_access_token, repo_id, py_file, ref=default_branch + ) + if content and pkg_check.lower() in content.lower(): + found = True + break + except Exception as e: + logger.debug(f"Failed to check {py_file}: {e}") + + if not found and "package.json" in paths: + try: + content = await github_service.get_file_content( + user_access_token, repo_id, "package.json", ref=default_branch + ) + if content and pkg_check.lower() in content.lower(): + found = True + except Exception as e: + logger.debug(f"Failed to check package.json: {e}") + + if not found: + return False + + return True + + def _path_matches(self, paths: set[str], pattern: str) -> bool: + """Check if pattern matches any path (supports globs).""" + if "*" in pattern or "?" in pattern: + return any(fnmatch.fnmatch(p, pattern) for p in paths) + return pattern in paths + + async def detect_with_commands( + self, + github_service: GitHubService, + user_access_token: str, + repo_id: int, + default_branch: str, + ) -> dict: + """Detect preset and extract build/start commands from package.json. + + Returns: + Dictionary with preset, build_command, start_command + """ + result = { + "preset": None, + "build_command": None, + "start_command": None, + } + + preset = await self.detect( + github_service, user_access_token, repo_id, default_branch + ) + result["preset"] = preset + + if not preset: + return result + + if preset in ("nodejs", "bun"): + try: + content = await github_service.get_file_content( + user_access_token, repo_id, "package.json", ref=default_branch + ) + + if content: + data = json.loads(content) + scripts = data.get("scripts", {}) + + for build_key in ("build", "compile", "bundle"): + if build_key in scripts: + result["build_command"] = f"npm run {build_key}" + break + + if "start" in scripts: + result["start_command"] = "npm start" + elif "serve" in scripts: + result["start_command"] = "npm run serve" + elif "dev" in scripts and not result["start_command"]: + result["start_command"] = "npm run dev" + + except Exception as e: + logger.debug(f"Failed to extract commands from package.json: {e}") + + return result diff --git a/app/settings/presets.json b/app/settings/presets.json index 458dda2..51de195 100644 --- a/app/settings/presets.json +++ b/app/settings/presets.json @@ -7,7 +7,11 @@ "build_command": "pip install -r requirements.txt", "pre_deploy_command": "", "start_command": "gunicorn -w 3 -b 0.0.0.0:8000 main:app", - "logo": "" + "logo": "", + "detection": { + "priority": 30, + "any_files": ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "poetry.lock"] + } }, { "slug": "flask", @@ -17,7 +21,13 @@ "build_command": "pip install -r requirements.txt", "pre_deploy_command": "", "start_command": "gunicorn -w 3 -b 0.0.0.0:8000 main:app", - "logo": "" + "logo": "", + "detection": { + "priority": 90, + "any_files": ["requirements.txt", "pyproject.toml", "*/requirements.txt"], + "none_files": ["manage.py"], + "package_check": "flask" + } }, { "slug": "django", @@ -27,7 +37,12 @@ "build_command": "pip install -r requirements.txt", "pre_deploy_command": "python manage.py migrate", "start_command": "gunicorn -w 3 -b 0.0.0.0:8000 myproject.wsgi:application", - "logo": "" + "logo": "", + "detection": { + "priority": 100, + "any_files": ["manage.py", "*/manage.py"], + "any_paths": ["*/settings.py", "*/wsgi.py"] + } }, { "slug": "fastapi", @@ -37,10 +52,15 @@ "build_command": "pip install -r requirements.txt", "pre_deploy_command": "", "start_command": "gunicorn -w 3 -b 0.0.0.0:8000 -k uvicorn.workers.UvicornWorker main:app", - "logo": "" + "logo": "", + "detection": { + "priority": 95, + "any_files": ["requirements.txt", "pyproject.toml", "*/requirements.txt"], + "none_files": ["manage.py"], + "package_check": "fastapi" + } }, { - "beta": true, "slug": "nodejs", "name": "Node.js", "category": "Node.js", @@ -48,10 +68,15 @@ "build_command": "npm install && npm run build", "pre_deploy_command": "", "start_command": "npm start", - "logo": "" + "logo": "", + "beta": true, + "detection": { + "priority": 40, + "any_files": ["package.json"], + "none_files": ["bun.lockb", "bun.lock"] + } }, { - "beta": true, "slug": "bun", "name": "Bun", "category": "Bun", @@ -59,10 +84,15 @@ "build_command": "bun install", "pre_deploy_command": "", "start_command": "bun run start", - "logo": "" + "logo": "", + "beta": true, + "detection": { + "priority": 95, + "any_files": ["bun.lockb", "bun.lock"], + "all_files": ["package.json"] + } }, { - "beta": true, "slug": "go", "name": "Go", "category": "Go", @@ -70,10 +100,14 @@ "build_command": "go mod download && go build -o app .", "pre_deploy_command": "", "start_command": "./app", - "logo": "" + "logo": "", + "beta": true, + "detection": { + "priority": 100, + "any_files": ["go.mod"] + } }, { - "beta": true, "slug": "php", "name": "PHP", "category": "PHP", @@ -81,10 +115,15 @@ "build_command": "composer install --no-dev --optimize-autoloader --no-interaction --no-progress", "pre_deploy_command": "", "start_command": "php-fpm -F & caddy run --config /etc/caddy/Caddyfile --adapter caddyfile", - "logo": "" + "logo": "", + "beta": true, + "detection": { + "priority": 50, + "any_files": ["composer.json", "index.php", "*/index.php"], + "none_files": ["artisan"] + } }, { - "beta": true, "slug": "laravel", "name": "Laravel", "category": "PHP", @@ -92,6 +131,12 @@ "build_command": "composer install --no-dev --optimize-autoloader --no-interaction --no-progress", "pre_deploy_command": "php artisan config:cache && php artisan route:cache && php artisan view:cache", "start_command": "frankenphp run --config /etc/caddy/Caddyfile --adapter caddyfile", - "logo": "" + "logo": "", + "beta": true, + "detection": { + "priority": 100, + "any_files": ["artisan"], + "all_files": ["composer.json"] + } } ] diff --git a/app/src/styles.css b/app/src/styles.css index 016dbbe..35e0120 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -6,7 +6,8 @@ @source inline("dark:from-{red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose,neutral}-950/50 dark:to-{red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose,neutral}-950 dark:border-{red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose,neutral}-900"); @source inline("text-{red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose,neutral}-500 border-{red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose,neutral}-500"); @source inline("bg-{red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose,neutral}-500"); +@source inline("[&>svg]:!text-{red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose,neutral}-500"); @source inline("bg-muted"); @import "basecoat-css"; -@import "./custom.css" \ No newline at end of file +@import "./custom.css"; \ No newline at end of file diff --git a/app/templates/admin/partials/_settings-allowlist.html b/app/templates/admin/partials/_settings-allowlist.html index 441cdca..490a777 100644 --- a/app/templates/admin/partials/_settings-allowlist.html +++ b/app/templates/admin/partials/_settings-allowlist.html @@ -2,7 +2,7 @@ {% call dialog( id="dialog-add-allowlist", - title=_("Add allowlist rule"), + title=_("Add an allowlist rule"), description=_('Add a new rule to allow sign up/sign in. You can add email addresses (e.g., user@example.com), domains (e.g., example.com) or regex patterns (e.g., ^[^@]+@example\\.com$).'), dialog_attrs={"class": "max-w-md"}, trigger_attrs={"class": "btn-outline"} diff --git a/app/templates/admin/partials/_settings-installation.html b/app/templates/admin/partials/_settings-installation.html index b0b92cb..b5f447e 100644 --- a/app/templates/admin/partials/_settings-installation.html +++ b/app/templates/admin/partials/_settings-installation.html @@ -1,3 +1,5 @@ +{% from "macros/copy.html" import copy %} +

{{ _('Installation') }}

@@ -18,7 +20,7 @@

{{ _('Checking for updates...') }}

{% else %} diff --git a/app/templates/admin/partials/_settings-users-content.html b/app/templates/admin/partials/_settings-users-content.html index 5e24afe..109ce8a 100644 --- a/app/templates/admin/partials/_settings-users-content.html +++ b/app/templates/admin/partials/_settings-users-content.html @@ -38,7 +38,10 @@ {{ user.created_at | time_ago }} {% if user.status == "deleted" %} - {{ _('Deleted') }} + + {% include "icons/loader.svg" %} + {{ _('Deleting') }} + {% else %} {{ _('Active') }} {% endif %} diff --git a/app/templates/deployment/macros/list.html b/app/templates/deployment/macros/list.html index 99adbd3..63dea95 100644 --- a/app/templates/deployment/macros/list.html +++ b/app/templates/deployment/macros/list.html @@ -16,9 +16,7 @@ {% from "project/macros/environment-label.html" import environment_label %}
    {% for deployment in deployments %} -
  • +
  • {{ status( conclusion=deployment.conclusion, compact=True, @@ -170,18 +168,17 @@
    -

    {{ _('No deployments') }}

    -

    {{ _('This project has no deployments yet. Push code to your repository or trigger a deployment below.') }}

    - {% from "macros/dialog.html" import dialog %} - diff --git a/app/templates/icons/database.svg b/app/templates/icons/database.svg new file mode 100644 index 0000000..c86d4a0 --- /dev/null +++ b/app/templates/icons/database.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/icons/hard-drive.svg b/app/templates/icons/hard-drive.svg new file mode 100644 index 0000000..957374a --- /dev/null +++ b/app/templates/icons/hard-drive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/icons/layers.svg b/app/templates/icons/layers.svg new file mode 100644 index 0000000..0aa0fa8 --- /dev/null +++ b/app/templates/icons/layers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/icons/square-stack.svg b/app/templates/icons/square-stack.svg new file mode 100644 index 0000000..999cb6d --- /dev/null +++ b/app/templates/icons/square-stack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/icons/unplug.svg b/app/templates/icons/unplug.svg new file mode 100644 index 0000000..2d2f9b2 --- /dev/null +++ b/app/templates/icons/unplug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/icons/upload.svg b/app/templates/icons/upload.svg new file mode 100644 index 0000000..9fc43a8 --- /dev/null +++ b/app/templates/icons/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/macros/avatar.html b/app/templates/macros/avatar.html index 6be1bcc..22d7859 100644 --- a/app/templates/macros/avatar.html +++ b/app/templates/macros/avatar.html @@ -10,7 +10,8 @@ {% if item.has_avatar and not use_fallback %} {{ item.name }} { + button.dataset.tooltip = "{{ _('Copy') }}"; + delete button.dataset.copied; + }, 2000); + }).catch(err => { + console.error("Failed to copy text: ", err); + }); + ' +> + {% include "icons/copy.svg" %} + + +{% endmacro %} diff --git a/app/templates/macros/dialog.html b/app/templates/macros/dialog.html index 53e6247..b941b60 100644 --- a/app/templates/macros/dialog.html +++ b/app/templates/macros/dialog.html @@ -83,12 +83,10 @@

    {{ title | safe }}

    {% endif %} {% if close_button %} -
    - -
    {% endif %}
    -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/app/templates/macros/dropdown-menu.html b/app/templates/macros/dropdown-menu.html index b75fd78..09d831a 100644 --- a/app/templates/macros/dropdown-menu.html +++ b/app/templates/macros/dropdown-menu.html @@ -86,7 +86,7 @@ {% endif %} >
    {{ item.label }}
    - {{ render_dropdown_items(item.items, item_id) if item.items }} + {{ render_dropdown_items(item['items'], item_id) if item['items'] }}
    {% elif item.type == "separator" %}
    @@ -102,6 +102,7 @@ {% endfor %} {% endif %} > + {% if item.icon %}{{ item.icon | safe }}{% endif %} {{ item.label | safe }} {% else %} @@ -114,9 +115,10 @@ {% endfor %} {% endif %} > + {% if item.icon %}{{ item.icon | safe }}{% endif %} {{ item.label | safe }} {% endif %} {% endif %} {% endfor %} -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/app/templates/macros/icon-avatar.html b/app/templates/macros/icon-avatar.html new file mode 100644 index 0000000..1642721 --- /dev/null +++ b/app/templates/macros/icon-avatar.html @@ -0,0 +1,35 @@ +{% macro icon_avatar(color=None, icon=None, type=None, size=None, class=None, attrs={}) %} +{% set storage_types = { + "database": {"color": "sky", "icon": "database"}, + "volume": {"color": "amber", "icon": "hard-drive"}, + "kv": {"color": "rose", "icon": "layers"}, + "queue": {"color": "green", "icon": "square-stack"} +} %} +{% if type and type in storage_types %} + {% set color = color or storage_types[type].color %} + {% set icon = icon or storage_types[type].icon %} +{% endif %} +
    + {% include "icons/" + icon + ".svg" %} +
    +{% endmacro %} \ No newline at end of file diff --git a/app/templates/macros/image-upload.html b/app/templates/macros/image-upload.html index db6929d..5fdc11d 100644 --- a/app/templates/macros/image-upload.html +++ b/app/templates/macros/image-upload.html @@ -16,6 +16,7 @@ maxSize: {{ max_size }}, error: null, remove: false, + loading: false, validateAndPreview(event) { const file = event.target.files[0]; if (!file) return; @@ -28,10 +29,12 @@ } this.error = null; + this.loading = true; const reader = new FileReader(); reader.onload = (e) => { this.preview = e.target.result; this.remove = false; + this.loading = false; $el.closest('form').dispatchEvent(new Event('change')); }; reader.readAsDataURL(file); @@ -44,7 +47,7 @@ }" >
    -
    +
    {{ current_image }} @@ -54,15 +57,25 @@ + + {% include "icons/loader.svg" %} +
    -
  • + {% if get_access(role, "creator") %}
  • {% endif %}
- \ No newline at end of file + diff --git a/app/templates/partials/_tabs-team.html b/app/templates/partials/_tabs-team.html index 35b5705..b25f396 100644 --- a/app/templates/partials/_tabs-team.html +++ b/app/templates/partials/_tabs-team.html @@ -26,6 +26,20 @@ hx-boost="true" >{{ _('Projects') }} + + {% if get_access(role, "admin") %}
  • {% endif %} - \ No newline at end of file + diff --git a/app/templates/project/macros/build-and-deploy.html b/app/templates/project/macros/build-and-deploy.html index 902888d..0bd7294 100644 --- a/app/templates/project/macros/build-and-deploy.html +++ b/app/templates/project/macros/build-and-deploy.html @@ -16,7 +16,7 @@
    {{ _('Framework preset') }}
    {% from "macros/select.html" import select %} - {% call select( + {% set default_option %} + + {% include "icons/circle-off.svg" %} + {{ _('None') }} + + {% endset %} + {% set preset_options = namespace(items=[{"label": default_option, "value": ""}]) %} + {% for preset in presets %} + {% set label %} + + {{ preset.logo | safe }} + {{ preset.name }} + {% if preset.beta %} + Beta + {% endif %} + + {% endset %} + {% set preset_options.items = preset_options.items + [{"label": label, "value": preset.slug}] %} + {% endfor %} + {{ select( name="preset", + items=preset_options.items, selected=form.preset.data, main_attrs={'@change': 'setPreset(event.detail.value, true)'}, is_combobox=True, listbox_attrs={'class': 'max-h-[162px] overflow-y-auto scrollbar'} - ) %} -
    - - {% include "icons/circle-off.svg" %} - {{ _('None') }} - -
    - {% for preset in presets %} -
    - - {{ preset.logo | safe }} - {{ preset.name | safe }} - {% if preset.beta %} - Beta - {% endif %} - -
    - {% endfor %} - {% endcall %} + ) }} + + +{% endset %} +{% call dialog( + id="dialog-env-paste", + title=_("Paste .env file"), + description=_("Paste the contents of your .env file here to add the variables to your environment variables list."), + footer=footer, + dialog_attrs={ + "class": "max-w-md", + "x-data": "{ envFile: '' }" + } +) %} + +{% endcall %} +
    - {% set items = [{ - "label": _('All environments'), - "value": "" - }] %} + {% set items = [{"label": _('All environments'), "value": ""}] %} {% for environment in environments %} - {% set items = items.append({ - "label": environment_label(environment), - "value": environment.slug - }) %} + {% set _ = items.append({"label": environment_label(environment), "value": environment.slug}) %} {% endfor %} {{ select( name='env_vars-' ~ loop.index0 ~ '-environment', @@ -136,15 +174,9 @@ >
    - {% set items = [{ - "label": _('All environments'), - "value": "" - }] %} + {% set items = [{"label": _('All environments'), "value": ""}] %} {% for environment in environments %} - {% set items = items.append({ - "label": environment_label(environment), - "value": environment.slug - }) %} + {% set _ = items.append({"label": environment_label(environment), "value": environment.slug}) %} {% endfor %} {{ select( name="", diff --git a/app/templates/project/pages/deployments.html b/app/templates/project/pages/deployments.html index 1aa72cc..41f69b6 100644 --- a/app/templates/project/pages/deployments.html +++ b/app/templates/project/pages/deployments.html @@ -32,7 +32,7 @@

    > {% from "macros/select.html" import select %} - {# Environments #} - {% set items = [{ - "label": _('All environments'), - "value": "" - }] %} - {% from "project/macros/environment-label.html" import environment_label %} - {% for environment in project.active_environments %} - {% set items = items.append({ - "label": environment_label(environment), - "value": environment['slug'] - }) %} - {% endfor %} - {{ select( - name='environment', - selected=request.query_params.get('environment'), - items=items, - trigger_attrs={'class': 'gap-1.5 h-8 px-3'} - ) }} +
    + {# Environments #} + {% set items = [{ + "label": _('All environments'), + "value": "" + }] %} + {% from "project/macros/environment-label.html" import environment_label %} + {% for environment in project.active_environments %} + {% set items = items.append({ + "label": environment_label(environment), + "value": environment['slug'] + }) %} + {% endfor %} + {{ select( + name='environment', + selected=request.query_params.get('environment'), + items=items, + trigger_attrs={'class': 'gap-1.5 h-8 px-3'} + ) }} - {% from "deployment/macros/status.html" import status %} + {% from "deployment/macros/status.html" import status %} - {% set items = [{ - "label": _('All statuses'), - "value": "" - }] %} - {% for conclusion in ['succeeded', 'failed', 'skipped', 'canceled', 'in_progress'] %} - {% set items = items.append({ - "label": status(conclusion=conclusion if conclusion != 'in_progress' else None) | safe, - "value": conclusion - }) %} - {% endfor %} + {% set items = [{ + "label": _('All statuses'), + "value": "" + }] %} + {% for conclusion in ['succeeded', 'failed', 'skipped', 'canceled', 'in_progress'] %} + {% set items = items.append({ + "label": status(conclusion=conclusion if conclusion != 'in_progress' else None) | safe, + "value": conclusion + }) %} + {% endfor %} - {{ select( - name="status", - selected=request.query_params.get("status"), - items=items, - trigger_attrs={"class": "gap-1.5 h-8 px-3"} - ) }} + {{ select( + name="status", + selected=request.query_params.get("status"), + items=items, + trigger_attrs={"class": "gap-1.5 h-8 px-3"} + ) }} - {% from "macros/popover.html" import popover %} - {% set date_range_trigger %} - - - {{ _('From') }} - + {% from "macros/popover.html" import popover %} + {% set date_range_trigger %} + + + {{ _('From') }} + + + + + + + + {{ _('All dates') }} + - - - + + {% include "icons/chevron-down.svg" %} - - {{ _('All dates') }} - - - - {% include "icons/chevron-down.svg" %} - - {% endset %} - {% call popover( - trigger=date_range_trigger, - trigger_attrs={'class': 'btn-outline font-normal gap-1.5 h-8 px-3'}, - popover_attrs={ - 'class': 'form p-4 space-y-4 min-w-auto', - 'data-align': 'start' - } - ) %} -
    - -
    - - + {% endset %} + {% call popover( + trigger=date_range_trigger, + trigger_attrs={'class': 'btn-outline font-normal gap-1.5 h-8 px-3'}, + popover_attrs={ + 'class': 'form p-4 space-y-4 min-w-auto', + 'data-align': 'start' + } + ) %} +
    + +
    + + +
    -
    -
    - -
    - - +
    + +
    + + +
    -
    - {% endcall %} + {% endcall %} - {% set items = [{ - "label": _('All branches'), - "value": "" - }] %} - {% for branch in branches %} - {% set items = items.append({ - "label": branch['name'], - "value": branch['name'] - }) %} - {% endfor %} - {{ select( - name="branch", - selected=request.query_params.get("branch"), - items=items, - trigger_attrs={'class': 'gap-1.5 h-8 px-3'} - ) }} + {% set items = [{ + "label": _('All branches'), + "value": "" + }] %} + {% for branch in branches %} + {% set items = items.append({ + "label": branch['name'], + "value": branch['name'] + }) %} + {% endfor %} + {{ select( + name="branch", + selected=request.query_params.get("branch"), + items=items, + trigger_attrs={'class': 'gap-1.5 h-8 px-3'} + ) }} +
    + {% endset %} + {% call popover( + trigger=date_range_trigger, + trigger_attrs={"class": "btn-outline font-normal gap-1.5 h-8 px-3"}, + popover_attrs={ + "class": "form p-4 space-y-4 min-w-auto", + "data-align": "start" + } + ) %} +
    + +
    + + + +
    -
    -
    - -
    - - - +
    + +
    + + + +
    -
    - {% endcall %} - - + {% endcall %} - {% set items = [{ - "label": _('All branches'), - "value": "" - }] %} - {% for branch in branches %} - {% set items = items.append({ - "label": branch['name'], - "value": branch['name'] - }) %} - {% endfor %} - {{ select( - id="filter-branch", - name="branch", - selected=request.query_params.get("branch"), - items=items, - trigger_attrs={'class': 'gap-1.5 h-8 px-3'} - ) }} + - + {% set items = [{ + "label": _('All branches'), + "value": "" + }] %} + {% for branch in branches %} + {% set items = items.append({ + "label": branch['name'], + "value": branch['name'] + }) %} + {% endfor %} + {{ select( + id="filter-branch", + name="branch", + selected=request.query_params.get("branch"), + items=items, + trigger_attrs={'class': 'gap-1.5 h-8 px-3'} + ) }} +
    + +
    diff --git a/app/templates/project/pages/new-details.html b/app/templates/project/pages/new-details.html index 0a4710d..6afafea 100644 --- a/app/templates/project/pages/new-details.html +++ b/app/templates/project/pages/new-details.html @@ -3,7 +3,7 @@ {% block app_content %}

    - {{ _('New project') }} + {{ _('Create a new project') }}

      diff --git a/app/templates/project/pages/new.html b/app/templates/project/pages/new.html index beb3703..74c0e70 100644 --- a/app/templates/project/pages/new.html +++ b/app/templates/project/pages/new.html @@ -14,7 +14,7 @@ " >

      - {{ _('New project') }} + {{ _('Create a new project') }}

        diff --git a/app/templates/project/pages/storage.html b/app/templates/project/pages/storage.html new file mode 100644 index 0000000..af11708 --- /dev/null +++ b/app/templates/project/pages/storage.html @@ -0,0 +1,240 @@ +{% extends "layouts/app.html" %} + +{% block app_content %} +{% from "macros/avatar.html" import avatar with context %} +{% from "project/macros/environment-label.html" import environment_label %} +{% from "macros/tabs.html" import tabs %} +{% from "macros/icon-avatar.html" import icon_avatar %} +{% from "macros/dropdown-menu.html" import dropdown_menu %} +{% from "macros/dialog.html" import dialog %} +{% from "macros/select.html" import select %} +{% from "macros/popover.html" import popover %} +{% from "macros/copy.html" import copy %} + +{% set storage_types = { + "database": _("Database"), + "volume": _("Volume"), + "kv": _("KV"), + "queue": _("Queue") +} %} + +{% include "project/partials/_dialog-project-storage.html" %} + +
        +
        +

        + + {{ avatar(item=project, prefix="project", name=project.name, size="md") }} + + / + {{ _("Storage") }} +

        + + {% if associations %} + + {% endif %} +
        + + {% if associations %} +
        +
          + {% for association in associations %} +
        • + + {{ icon_avatar(type=association.storage.type, size="xs") }} + {{ association.storage.name }} + + +
          + {% if association.environment_ids %} + {% for env_id in association.environment_ids %} + {% set env = project.get_environment_by_id(env_id) %} + {{ env.name }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% else %} + {{ _('All environments') }} + {% endif %} +
          + + {% set storage = association.storage %} + {% include "project/partials/_storage-status.html" %} + +
          + {% set trigger %} + {% include "icons/info.svg" %} + {% endset %} + {% call popover( + trigger=trigger, + trigger_attrs={ + "class": "btn-sm-icon-ghost", + "aria-label": _('Usage'), + "data-tooltip": _('Usage'), + }, + popover_attrs={ + "data-align": "end", + "class": "max-w-xs p-0" + } + ) %} +
          +

          {{ _('Usage') }}

          +
          + {% if association.storage.type == "database" %} +

          {{ _('You can access this SQLite database in any of the selected environments at:') }}

          +
          + {% set database_path = "/data/database/" ~ association.storage.name ~ "/db.sqlite" %} + + {{ copy(value=database_path, class="absolute right-1.5 top-1/2 -translate-y-1/2 size-6") }} + {% elif association.storage.type == "volume" %} +

          {{ _('You can access this volume in any of the selected environments at:') }}

          +
          + {% set volume_path = "/data/volume/" ~ association.storage.name ~ "/" %} + + {{ copy(value=volume_path, class="absolute right-1.5 top-1/2 -translate-y-1/2 size-6") }} +
          + {% else %} +

          {{ _('Use the connection details provided in this project’s settings.') }}

          + {% endif %} +
          +
          + {% endcall %} + + {% if get_access(role, "admin") or association.storage.created_by_user_id == current_user.id %} + {% set icon_unplug %}{% include "icons/unplug.svg" %}{% endset %} + {% set icon_edit %}{% include "icons/pencil.svg" %}{% endset %} + {% set icon_settings %}{% include "icons/settings.svg" %}{% endset %} + {% set trigger %} + {% include "icons/ellipsis.svg" %} + {% endset %} + {% call dropdown_menu( + trigger=trigger, + trigger_attrs={"class": "btn-sm-icon-ghost [&>svg]:shrink-0"}, + popover_attrs={"data-align": "end"}, + items=[ + { + "icon": icon_edit, + "label": _("Environments"), + "attrs": { + "onclick": "document.getElementById('dialog-storage-environments-" ~ association.id ~ "').showModal()" + }, + }, + { + "icon": icon_settings, + "label": _("Settings"), + "url": url_for('team_storage_item', team_slug=team.slug, storage_name=association.storage.name), + }, + {"type": "separator"}, + { + "icon": icon_unplug, + "label": _("Disconnect"), + "attrs": { + "class": "text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/20 focus:bg-destructive/10 dark:focus:bg-destructive/20 focus:text-destructive [&_svg]:!text-destructive", + "onclick": "document.getElementById('dialog-storage-disconnect-" ~ association.id ~ "').showModal()" + }, + } + ] + ) %} + {% include "deployment/partials/_options.html" with context %} + {% endcall %} + {% endif %} +
          + + {% if get_access(role, "admin") or association.storage.created_by_user_id == current_user.id %} + {% call dialog( + id="dialog-storage-environments-" ~ association.id, + title=_('Edit environments', storage=association.storage.name), + description=_('Select the environments that will have access to this storage.', project=project.name), + dialog_attrs={"class": "max-w-lg"} + ) %} + {% include "project/partials/_dialog-project-storage-environments-form.html" with context %} + {% endcall %} + + {% call dialog( + id="dialog-storage-disconnect-" ~ association.id, + title=_('Disconnect "%(project)s"?', project=project.name, storage=association.storage.name), + dialog_attrs={"class": "max-w-md"} + ) %} +
          +

          {{ _('This will disconnect the "%(storage)s" storage from the "%(project)s" project. Any new deployment for this project will no longer have access to this storage.', project=project.name, storage=association.storage.name) | safe }}

          +

          {{ _('To confirm, enter the project name "%(project)s" below.', project=project.name) | safe }}

          +
          + {{ remove_storage_form.csrf_token(id=False) }} + {{ remove_storage_form.association_id(id=False, value=association.id) }} + {{ remove_storage_form.confirm( + id=False, + class_='w-full input', + placeholder=_('Type the project name to confirm'), + **{ + "x-model": "confirm", + "@keydown.enter": "confirm !== '"~project.name~"' ? $event.preventDefault() : null" + } + ) }} +
          + + +
          +
          +
          + {% endcall %} + {% endif %} +
        • + {% endfor %} +
        +
        + {% else %} +
        +
        +
        +

        {{ _('No storage connected.') }}

        +

        {{ _('Connect new or existing storage to this project.') }}

        + +
        +
        +
        + {% endif %} +
        +{% endblock %} diff --git a/app/templates/project/partials/_dialog-deploy-commits-skeleton.html b/app/templates/project/partials/_dialog-deploy-commits-skeleton.html index fde4b4f..039e892 100644 --- a/app/templates/project/partials/_dialog-deploy-commits-skeleton.html +++ b/app/templates/project/partials/_dialog-deploy-commits-skeleton.html @@ -11,7 +11,6 @@ {% endfor %}
    - +
    \ No newline at end of file diff --git a/app/templates/project/partials/_dialog-deploy-commits.html b/app/templates/project/partials/_dialog-deploy-commits.html index 0a15545..0ce78d2 100644 --- a/app/templates/project/partials/_dialog-deploy-commits.html +++ b/app/templates/project/partials/_dialog-deploy-commits.html @@ -13,8 +13,7 @@

    {{ _('No commits') }}

    - + @@ -78,7 +77,7 @@

    {{ _('No commits') }}

    {% endfor %}
    - + + +
    + diff --git a/app/templates/project/partials/_dialog-project-storage-form.html b/app/templates/project/partials/_dialog-project-storage-form.html new file mode 100644 index 0000000..6730b2f --- /dev/null +++ b/app/templates/project/partials/_dialog-project-storage-form.html @@ -0,0 +1,134 @@ +{% from "macros/select.html" import select %} +{% from "project/macros/environment-label.html" import environment_label %} +{% from "macros/icon-avatar.html" import icon_avatar %} + +{% set database_label %} +
    + {{ icon_avatar(type="database", size="xs") }} + {{ _("Database (SQLite)") }} +
    +{% endset %} +{% set volume_label %} +
    + {{ icon_avatar(type="volume", size="xs") }} + {{ _("Volume") }} +
    +{% endset %} +{% set kv_label %} +
    + {{ icon_avatar(type="kv", size="xs") }} + {{ _("KV (Redis/ValKey)") }} +
    +{% endset %} +{% set queue_label %} +
    + {{ icon_avatar(type="queue", size="xs") }} + {{ _("Queue (Redis/ValKey)") }} +
    +{% endset %} + +{% set type_icons = { + "database": {"color": "sky", "icon": "database"}, + "volume": {"color": "amber", "icon": "hard-drive"}, + "kv": {"color": "rose", "icon": "layers"}, + "queue": {"color": "green", "icon": "square-stack"} +} %} + +
    + {{ form.csrf_token }} + {% if fragment == 'create_storage' %} +
    + {{ form.type.label(class_="label") }} + {{ select( + name="type", + selected=form.type.data, + items=[ + {"label": database_label, "value": "database"}, + {"label": volume_label, "value": "volume"}, + {"label": kv_label, "value": "kv", "attrs": {"aria-disabled": "true"}}, + {"label": queue_label, "value": "queue", "attrs": {"aria-disabled": "true"}}, + ], + ) }} +
    + {% endif %} +
    + {% if fragment == 'create_storage' %} + {{ form.name.label(class_="label") }} + {{ form.name(class_="input", placeholder=_("Storage name")) }} +

    {{ _('Can only contain letters, numbers, hyphens, underscores and dots.') }}

    + {% for error in form.name.errors %} +

    {{ error }}

    + {% endfor %} + {% else %} + {{ form.storage_id.label(class_="label") }} + + {{ form.association_id }} + {% if storages %} + {% set items = [] %} + {% for storage in storages %} + {% set label %} +
    + {{ icon_avatar(type=storage.type, size="xs") }} + {{ storage.name }} +
    + {% endset %} + {% set _ = items.append({"label": label, "value": storage.id}) %} + {% endfor %} + {{ select( + name="storage_id", + selected=form.storage_id.data, + is_combobox=true, + items=items, + main_attrs={"class": "w-full"}, + trigger_attrs={ + "aria-invalid": "true" if form.storage_id.errors else "", + "class": "w-full truncate" + } + ) }} + {% for error in form.storage_id.errors %} +

    {{ error }}

    + {% endfor %} + {% else %} +

    {{ _('No available storage.') }}

    + {% endif %} + {% endif %} +
    + +
    + {{ form.environment_ids.label(class_="label") }} + {% set items = [] %} + {% for environment in project.active_environments %} + {% set label %}{{ environment_label(environment) }}{% endset %} + {% set _ = items.append({"label": label, "value": environment.id}) %} + {% endfor %} + {{ select( + name="environment_ids", + selected=form.environment_ids.data, + is_combobox=true, + multiple=true, + placeholder=_("All environments"), + items=items, + main_attrs={"class": "w-full"}, + trigger_attrs={ + "aria-invalid": "true" if form.environment_ids.errors else "", + "class": "w-full truncate" + } + ) }} + {% for error in form.environment_ids.errors %} +

    {{ error }}

    + {% endfor %} +
    + +
    + + +
    +
    diff --git a/app/templates/project/partials/_dialog-project-storage.html b/app/templates/project/partials/_dialog-project-storage.html new file mode 100644 index 0000000..c83b9d2 --- /dev/null +++ b/app/templates/project/partials/_dialog-project-storage.html @@ -0,0 +1,48 @@ +{% from "macros/dialog.html" import dialog %} +{% from "macros/tabs.html" import tabs %} + +{% call dialog( + id="dialog-project-storage", + title=_("Add storage"), + description=_("Connect new or existing storage to this project."), + dialog_attrs={"class": "w-full max-w-xl"}, + body_attrs={"class": "space-y-4"} +) %} + +{% if available_storages | length > 0%} + {% set create_storage_panel %} + {% with form=create_storage_form, storages=None, fragment="create_storage" %} + {% include "project/partials/_dialog-project-storage-form.html" with context %} + {% endwith %} + {% endset %} + + {% set connect_storage_panel %} + {% with form=connect_storage_form, storages=available_storages, fragment="connect_storage" %} + {% include "project/partials/_dialog-project-storage-form.html" with context %} + {% endwith %} + {% endset %} + + {{ tabs( + tabsets=[ + { + "id": "create-storage", + "tab": _("Create new"), + "panel": create_storage_panel, + "panel_attrs": {"class": "pt-2"} + }, + { + "id": "connect-storage", + "tab": _("Connect existing"), + "panel": connect_storage_panel, + "panel_attrs": {"class": "pt-2"} + } + ], + main_attrs={"class": "w-full"}, + tablist_attrs={"class": "w-full"} + ) }} +{% else %} + {% with form=create_storage_form, storages=None, fragment="create_storage" %} + {% include "project/partials/_dialog-project-storage-form.html" with context %} + {% endwith %} +{% endif %} +{% endcall %} diff --git a/app/templates/project/partials/_form-build-and-deploy.html b/app/templates/project/partials/_form-build-and-deploy.html new file mode 100644 index 0000000..6660215 --- /dev/null +++ b/app/templates/project/partials/_form-build-and-deploy.html @@ -0,0 +1,2 @@ +{% from "project/macros/build-and-deploy.html" import render_build_and_deploy %} +{{ render_build_and_deploy(form, presets, images) }} \ No newline at end of file diff --git a/app/templates/project/partials/_form-new-project.html b/app/templates/project/partials/_form-new-project.html index f56d593..d603b5a 100644 --- a/app/templates/project/partials/_form-new-project.html +++ b/app/templates/project/partials/_form-new-project.html @@ -5,7 +5,8 @@ hx-swap="outerHTML" hx-disabled-elt="input, select, button" class="form flex flex-col gap-y-6" - hx-indicator="find .htmx-indicator" + hx-indicator="find div.htmx-indicator" + hx-disinherit="*" > {{ form.csrf_token(id=False) }} {{ form.repo_id(id=False, class_="hidden") }} @@ -38,7 +39,7 @@ size=100, class="w-full", required=True, - placeholder="e.g. my-project", + placeholder="e.g., my-project", **({'aria-invalid': 'true'} if form.name.errors else {}) ) }} {% for error in form.name.errors %} @@ -46,16 +47,30 @@ {% endfor %}
    -
    +
    + class="px-4 py-3 w-full flex items-center gap-x-2 group-open:[&>svg]:rotate-90 [&>svg]:transition-transform hover:bg-muted/50 rounded-lg transition-colors group-open:rounded-b-none [&>svg]:size-4 h-11"> {% include "icons/chevron-right.svg" %} {{ _('Build & Deploy') }} + + + {% include "icons/loader.svg" %} + {{ _('Detecting preset...') }} + -
    - {% from "project/macros/build-and-deploy.html" import render_build_and_deploy %} - {{ render_build_and_deploy(form, presets, images) }} -
    +
    + {% include "project/partials/_form-build-and-deploy.html" %} +
    -
    +
    {% from "project/macros/env-vars.html" import render_env_vars %} {{ render_env_vars(form, environments) }} -
    +
    diff --git a/app/templates/project/partials/_settings-danger.html b/app/templates/project/partials/_settings-danger.html index e64fd6b..f6a6ccb 100644 --- a/app/templates/project/partials/_settings-danger.html +++ b/app/templates/project/partials/_settings-danger.html @@ -23,7 +23,7 @@

    {{ _('Danger zone') }}

    {{ _('This will delete the project, its configuration, environments and deployments. This action cannot be undone.') }}

    {{ _('To confirm, enter the project name "%(name)s" below.', name=project.name) | safe }}

    {{ delete_project_form.csrf_token(id=False) }} diff --git a/app/templates/project/partials/_settings-domains.html b/app/templates/project/partials/_settings-domains.html index fe70e02..004facb 100644 --- a/app/templates/project/partials/_settings-domains.html +++ b/app/templates/project/partials/_settings-domains.html @@ -1,4 +1,5 @@ {% from "macros/select.html" import select %} +{% from "macros/copy.html" import copy %} {% from "macros/dialog.html" import dialog %} {% from "project/macros/environment-label.html" import environment_label %} @@ -10,12 +11,6 @@ "307": _("307 - Temporary Redirect"), } %} -{% set domain_statuses = { - "pending": _("Pending"), - "failed": _("Failed"), - "disabled": _("Disabled"), -} %} - {% macro render_domain_form(domain={}, index=0, update=False) %} {% if domain %} {% set is_active = domain_form.errors and domain_form.domain_id.data|int == domain.id %} @@ -275,21 +270,7 @@

    {{ _("This domain is disabled.") }}

    {{ env_id_hostname }} - + {{ copy(value=env_id_hostname) }} @@ -340,7 +321,6 @@

    {{ _("This domain is disabled.") }}

    @click="activeForm = 0" x-show="activeForm !== 0" :disabled="activeForm" - data-tooltip="{{ _('Add domain') }}" > {% include "icons/plus.svg" %} {{ _('Add domain') }} diff --git a/app/templates/project/partials/_settings-env-vars.html b/app/templates/project/partials/_settings-env-vars.html index 0e7659d..d3e7d80 100644 --- a/app/templates/project/partials/_settings-env-vars.html +++ b/app/templates/project/partials/_settings-env-vars.html @@ -1,52 +1,3 @@ -{% from "macros/dialog.html" import dialog %} -{% set footer %} - - -{% endset %} -{% call dialog( - id="dialog-env-paste", - title=_("Paste .env file"), - description=_("Paste the contents of your .env file here to add the variables to your environment variables list."), - footer=footer, - dialog_attrs={ - "class": "max-w-md", - "x-data": "{ envFile: '' }" - } -) %} - -{% endcall %} - -
    {{ _('Environments') }} @htmx:after-request="window.dispatchEvent(new Event('environments:updated'));" @submit.prevent="!confirm" > - {{ delete_environment_form.csrf_token(id=False) }} - {{ delete_environment_form.environment_id( + {{ remove_environment_form.csrf_token(id=False) }} + {{ remove_environment_form.environment_id( id=False, value=environment.id ) }} - {{ delete_environment_form.confirm( + {{ remove_environment_form.confirm( id=False, class_='w-full', placeholder=_('Type the environment identifier to confirm'), @@ -200,7 +200,7 @@

    {{ _('Environments') }}

    ) }}
    - {{ delete_environment_form.submit( + {{ remove_environment_form.submit( id=False, class_="btn-destructive", **{':disabled': "confirm !== '" + environment.slug + "'"} @@ -230,4 +230,4 @@

    {{ _('Environments') }}

    {% include "icons/loader.svg" %} {{ _('Saving') }}
    - \ No newline at end of file + diff --git a/app/templates/project/partials/_settings-general.html b/app/templates/project/partials/_settings-general.html index 575a529..f0cafbe 100644 --- a/app/templates/project/partials/_settings-general.html +++ b/app/templates/project/partials/_settings-general.html @@ -1,8 +1,10 @@ +{% from "macros/copy.html" import copy %} + {% if not (is_fragment and request.method == "POST") %} {% from "macros/dialog.html" import dialog %} {% call dialog( id="dialog-repo-select", - title="Select repository", + title=_("Select a repository"), dialog_attrs={ "class": "max-w-screen-sm w-full", "@repo-selected": "$el.close()", @@ -143,32 +145,11 @@

    {{ _("General") }}

    - + {{ copy(value=project.id, class="absolute right-1.5 top-1/2 -translate-y-1/2 size-6") }}

    {{ _("A unique identifier used in API, environment variables, etc.") }}

    diff --git a/app/templates/project/partials/_storage-status.html b/app/templates/project/partials/_storage-status.html new file mode 100644 index 0000000..0f1ff4f --- /dev/null +++ b/app/templates/project/partials/_storage-status.html @@ -0,0 +1,16 @@ +{% if storage.error %} + + {% include "icons/circle-x.svg" %} + {{ _('Error') }} + +{% elif storage.status == "pending" %} + + {% include "icons/loader.svg" %} + {{ _('Provisioning') }} + +{% endif %} diff --git a/app/templates/team/pages/index.html b/app/templates/team/pages/index.html index 8262ab9..9e9336c 100644 --- a/app/templates/team/pages/index.html +++ b/app/templates/team/pages/index.html @@ -35,7 +35,7 @@

    {{ _('Recent projects') }}

    {{ _('Deploy your first project.') }}

    -

    {{ _('Add a new project to deploy from an existing GitHub repository.') }} +

    {{ _('Add a new project from an existing GitHub repository.') }}

    {% include "icons/plus.svg" %} diff --git a/app/templates/team/pages/projects.html b/app/templates/team/pages/projects.html index 395e548..ee05d84 100644 --- a/app/templates/team/pages/projects.html +++ b/app/templates/team/pages/projects.html @@ -63,8 +63,8 @@

    class="flex items-center justify-center min-h-60 bg-card text-card-foreground border shadow-sm rounded-xl p-4">
    -

    {{ _('Deploy your first project.') }}

    -

    {{ _('Add a new project to deploy from an existing GitHub repository.') }} +

    {{ _('No projects yet.') }}

    +

    {{ _('Create a new project from a GitHub repository.') }}

    {% include "icons/plus.svg" %} diff --git a/app/templates/team/pages/storage-item.html b/app/templates/team/pages/storage-item.html new file mode 100644 index 0000000..213b675 --- /dev/null +++ b/app/templates/team/pages/storage-item.html @@ -0,0 +1,85 @@ +{% extends "layouts/app.html" %} + +{% block app_content %} +{% from "macros/avatar.html" import avatar with context %} +{% from "macros/icon-avatar.html" import icon_avatar %} +{% from "macros/copy.html" import copy %} + +{% set storage_types = { + "database": _("Database"), + "volume": _("Volume"), + "kv": _("KV"), + "queue": _("Queue") +} %} + +
    +
    +

    + + {{ avatar(item=team, prefix="team", name=team.name, size="md") }} + + / + + {{ _('Storage') }} + + / + + {{ storage_types[storage.type] }} + + / + {{ storage.name }} +

    +
    + +
    + + +
    +
    + {% include "icons/info.svg" %} +

    {{ _('Usage') }}

    +
    + {% if storage.type == "database" %} + {% set storage_path = "/data/database/" ~ storage.name ~ "/db.sqlite" %} + {% set storage_description = _('You can access this SQLite database in any of the selected environments at:') %} + {% else %} + {% set storage_path = "/data/volume/" ~ storage.name ~ "/" %} + {% set storage_description = _('You can access this volume in any of the selected environments at:') %} + {% endif %} +

    {{ storage_description }}

    +
    + + {{ copy(value=storage_path, class="absolute right-1.5 top-1/2 -translate-y-1/2 size-6") }} +
    +
    +
    + +
    + {% include "team/partials/_storage-general.html" %} +
    + +
    + {% include "team/partials/_storage-associations.html" %} +
    + + {% if get_access(role, "admin") %} +
    + {% include "team/partials/_storage-danger.html" %} +
    + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/app/templates/team/pages/storage.html b/app/templates/team/pages/storage.html new file mode 100644 index 0000000..8f159e2 --- /dev/null +++ b/app/templates/team/pages/storage.html @@ -0,0 +1,111 @@ +{% extends "layouts/app.html" %} + +{% block app_content %} +{% from "macros/avatar.html" import avatar with context %} +{% from "macros/icon-avatar.html" import icon_avatar %} + +{% include "team/partials/_dialog-new-storage.html" %} + +
    +
    +

    + + {% from "macros/avatar.html" import avatar with context %} + {{ avatar(item=team, prefix="team", name=team.name, size="md") }} + + / + {{ _("Storage") }} +

    + + {% if storage_count %} + + {% endif %} +
    + +
    + {% if storage_count %} + +
    + + {% include "icons/search.svg" %} +
    +
    + + + + + +
    + +
    +
    + {% include "team/partials/_storage-list.html" %} +
    +
    +
    + {% include "icons/loader.svg" %} + {{ _('Updating') }} +
    +
    +
    + {% else %} +
    +
    +
    +

    {{ _('No storage yet.') }}

    +

    {{ _('Create storage and connect it to projects.') }}

    + +
    +
    +
    + {% endif %} +
    +
    +{% endblock %} diff --git a/app/templates/team/partials/_dialog-new-storage-form.html b/app/templates/team/partials/_dialog-new-storage-form.html new file mode 100644 index 0000000..5533af3 --- /dev/null +++ b/app/templates/team/partials/_dialog-new-storage-form.html @@ -0,0 +1,80 @@ +{% from "macros/icon-avatar.html" import icon_avatar %} +{% from "macros/select.html" import select %} + +{% set database_label %} +
    + {{ icon_avatar(type="database", size="xs") }} + {{ _("Database (SQLite)") }} +
    +{% endset %} +{% set volume_label %} +
    + {{ icon_avatar(type="volume", size="xs") }} + {{ _("Volume") }} +
    +{% endset %} +{% set kv_label %} +
    + {{ icon_avatar(type="kv", size="xs") }} + {{ _("KV (Redis/ValKey)") }} +
    +{% endset %} +{% set queue_label %} +
    + {{ icon_avatar(type="queue", size="xs") }} + {{ _("Queue (Redis/ValKey)") }} +
    +{% endset %} + +
    + {{ form.csrf_token() }} +
    + {{ form.type.label(class_="label") }} + {{ select( + name="type", + selected=form.type.data, + items=[ + {"label": database_label, "value": "database"}, + {"label": volume_label, "value": "volume"}, + {"label": kv_label, "value": "kv", "attrs": {"aria-disabled": "true"}}, + {"label": queue_label, "value": "queue", "attrs": {"aria-disabled": "true"}}, + ], + ) }} +
    +
    + {{ form.name.label(class="label") }} + {{ form.name( + class="input w-full", + placeholder=_("e.g., my-app-db-prod"), + **{"x-model": "name", "autofocus": "true", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]$"} + ) }} +

    {{ _('Can only contain letters, numbers, hyphens, underscores and dots.') }}

    + {% for error in form.name.errors %} +

    {{ error }}

    + {% endfor %} +
    +
    + + +
    +
    diff --git a/app/templates/team/partials/_dialog-new-storage.html b/app/templates/team/partials/_dialog-new-storage.html new file mode 100644 index 0000000..9ae7551 --- /dev/null +++ b/app/templates/team/partials/_dialog-new-storage.html @@ -0,0 +1,11 @@ +{% from "macros/dialog.html" import dialog %} + +{% call dialog( + id="dialog-new-storage", + title=_("Create new storage"), + description=_("Create a new database, volume, KV or queue and connect it to one or more projects."), + dialog_attrs={"class": "w-full max-w-xl"}, + body_attrs={"class": "space-y-4"} +) %} + {% include "team/partials/_dialog-new-storage-form.html" %} +{% endcall %} diff --git a/app/templates/team/partials/_dialog-new-team.html b/app/templates/team/partials/_dialog-new-team-form.html similarity index 91% rename from app/templates/team/partials/_dialog-new-team.html rename to app/templates/team/partials/_dialog-new-team-form.html index bdb0978..7ef988d 100644 --- a/app/templates/team/partials/_dialog-new-team.html +++ b/app/templates/team/partials/_dialog-new-team-form.html @@ -18,7 +18,7 @@ {{ form.submit(class_="btn-primary", **{":disabled": "!name.trim()"}) }} diff --git a/app/templates/team/partials/_settings-general.html b/app/templates/team/partials/_settings-general.html index da50a2b..276d673 100644 --- a/app/templates/team/partials/_settings-general.html +++ b/app/templates/team/partials/_settings-general.html @@ -1,3 +1,7 @@ +{% from "macros/copy.html" import copy %} +{% from "macros/image-upload.html" import image_upload %} +{% from "macros/avatar.html" import avatar with context %} +
    {{ _("General") }}

    - {% from "macros/image-upload.html" import image_upload %} - - {% from "macros/avatar.html" import avatar with context %} {% set current_avatar %} {{ avatar(item=team, prefix="team", name=team.name, size="md") }} {% endset %} @@ -62,32 +63,11 @@

    {{ _("General") }}

    - + {{ copy(value=team.id, class="absolute right-1.5 top-1/2 -translate-y-1/2 size-6") }}

    {{ _("A unique identifier used in API, environment variables, etc.") }}

    diff --git a/app/templates/team/partials/_settings-members.html b/app/templates/team/partials/_settings-members.html index 053dfc9..94d2d96 100644 --- a/app/templates/team/partials/_settings-members.html +++ b/app/templates/team/partials/_settings-members.html @@ -133,21 +133,21 @@

    {{ _('Members') }}

    @submit.prevent="isValid && $el.requestSubmit()" @keydown.enter.prevent="if(isValid){ $el.closest('form').requestSubmit() }" > - {{ delete_member_form.csrf_token }} - {{ delete_member_form.email(value=member.user.email) }} + {{ remove_member_form.csrf_token }} + {{ remove_member_form.email(value=member.user.email) }}
    - {{ delete_member_form.confirm( + {{ remove_member_form.confirm( class_="w-full input", placeholder=_("Type the member's email to confirm"), ** {"@input": "isValid = $event.target.value === '" ~ member.user.email ~ "'"} ) }} - {% for error in delete_member_form.confirm.errors %} + {% for error in remove_member_form.confirm.errors %}

    {{ error }}

    {% endfor %}
    - {{ delete_member_form.submit(class_="btn-destructive", **{":disabled": "!isValid"}) }} + {{ remove_member_form.submit(class_="btn-destructive", **{":disabled": "!isValid"}) }}
    @@ -262,4 +262,4 @@

    {{ _('%(count)s pending invite(s)', count=member_invites {{ _("Saving") }}

    - \ No newline at end of file + diff --git a/app/templates/team/partials/_storage-associations.html b/app/templates/team/partials/_storage-associations.html new file mode 100644 index 0000000..ce88f5c --- /dev/null +++ b/app/templates/team/partials/_storage-associations.html @@ -0,0 +1,265 @@ +{% from "macros/avatar.html" import avatar with context %} +{% from "project/macros/environment-label.html" import environment_label %} +{% from "macros/select.html" import select %} +{% from "macros/dialog.html" import dialog %} + + +{% macro render_association_form(association={}, index=0, update=False) %} + {% if association %} + {% set selected_project = association.project %} + {% set selected_project_id = association.project_id %} + {% set selected_environment_ids = association.environment_ids %} + {% elif association_form.project_id.data %} + {% set selected_project_id = association_form.project_id.data %} + {% set selected_project = none %} + {% set selected_environment_ids = association_form.environment_ids.data %} + {% for project in projects %} + {% if project.id == selected_project_id %} + {% set selected_project = project %} + {% endif %} + {% endfor %} + {% else %} + {% set selected_project = default_project %} + {% set selected_project_id = default_project.id if default_project else "" %} + {% set selected_environment_ids = association_form.environment_ids.data %} + {% endif %} + + {% if association %} + {% set is_active = association_form.errors and association_form.association_id.data == association.id %} + {% else %} + {% set is_active = association_form.errors and not association_form.association_id.data %} + {% endif %} + +
    + {{ association_form.csrf_token(id=False) }} + {{ association_form.association_id(id=False, value=association.id if association else "") }} + {{ association_form.storage_id(id=False, value=storage.id) }} +
    + {% set items=[] %} + {% if association %} + {% set select_projects = [association.project] %} + {% else %} + {% set select_projects = available_projects %} + {% endif %} + {% for project in select_projects %} + {% set label %} +
    + {{ avatar(item=project, prefix="project", name=project.name, size="xs") }} + {{ project.name }} +
    + {% endset %} + {% set items = items.append({ + "label": label, + "value": project.id + }) %} + {% endfor %} + {% if association %} + {% set project_trigger_attrs = { + "aria-invalid": "true" if association_form.project_id.errors else "", + "class": "w-full truncate", + "disabled": "disabled", + "aria-disabled": "true" + } %} + {% else %} + {% set project_trigger_attrs = { + "aria-invalid": "true" if association_form.project_id.errors else "", + "class": "w-full truncate" + } %} + {% endif %} + {{ select( + name="project_id", + selected=selected_project_id, + is_combobox=true, + items=items, + main_attrs={"class": "w-full"}, + trigger_attrs=project_trigger_attrs, + input_attrs={"id": "storage-project-id"} + ) }} + {% if is_active %} + {% for error in association_form.project_id.errors %} +

    {{ error }}

    + {% endfor %} + {% endif %} +
    +
    + {% include "team/partials/_storage-select-environments.html" with context %} +
    + +
    + + +
    +
    +{% endmacro %} + +
    +
    +

    {{ _('Connected projects') }}

    +

    {{ _('Manage the projects connected to this storage.') }}

    +
    + +
    + {% if projects %} +
    +
    +
    {{ _('Project') }}
    +
    {{ _('Environments') }}
    +
    +
    + {% for association in associations %} +
    +
    + +
    + + {% if association.environment_ids | length > 0 %} + {% for environment_id in association.environment_ids %} + {{ association.project.get_environment_by_id(environment_id).name }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% else %} + {{ _('All environments') }} + {% endif %} + +
    +
    + + {% set trigger %} + {% include "icons/unplug.svg" %} + {% endset %} + {% call dialog( + trigger=trigger, + title=_('Disconnect "%(project)s"?', project=association.project.name, storage=storage.name), + trigger_attrs={ + "class": "btn-icon-ghost text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/20", + "aria-label": _('Disconnect'), + "data-tooltip": _('Disconnect'), + }, + dialog_attrs={"class": "max-w-md"} + ) %} +
    +

    {{ _('This will disconnect the "%(project)s" project from the "%(storage)s" storage. Any new deployment for this project will no longer have access to this storage.', project=association.project.name, storage=storage.name) | safe }}

    +

    {{ _('To confirm, enter the project name "%(project)s" below.', project=association.project.name) | safe }}

    +
    + {{ remove_association_form.csrf_token(id=False) }} + {{ remove_association_form.association_id( + id=False, + value=association.id + ) }} + {{ remove_association_form.confirm( + id=False, + class_='w-full', + placeholder=_('Type the project name to confirm'), + **{ + "x-model": "confirm", + "@keydown.enter": "confirm !== '"~association.project.name~"' ? $event.preventDefault() : null" + } + ) }} + +
    + + +
    +
    +
    + {% endcall %} +
    +
    + {{ render_association_form(association=association, index=loop.index) }} +
    + {% endfor %} + + {{ render_association_form(index=0) }} + + {% if available_projects %} + + {% endif %} +
    + {% else %} +
    +

    {{ _('No projects in this team yet.') }}

    +
    + {% endif %} +
    +
    + +
    +
    + {% include "icons/loader.svg" %} + {{ _('Saving') }} +
    +
    diff --git a/app/templates/team/partials/_storage-danger.html b/app/templates/team/partials/_storage-danger.html new file mode 100644 index 0000000..3a39777 --- /dev/null +++ b/app/templates/team/partials/_storage-danger.html @@ -0,0 +1,58 @@ +
    +
    +

    {{ _('Danger zone') }}

    +

    {{ _('Permanently delete the storage and all of its data.') }}

    +
    + +
    + {% if get_access(role, "admin") %} + {% from "macros/dialog.html" import dialog %} + {% set trigger %} + {% include "icons/trash-2.svg" %} + {{ _('Delete storage') }} + {% endset %} + {% call dialog( + trigger=trigger, + title=_('Delete "%(name)s"?', name=storage.name), + trigger_attrs={"class": "btn-destructive"}, + dialog_attrs={"class": "max-w-md"}, + close_button=false, + close_on_overlay_click=false + ) %} +
    +

    {{ _('This will delete the storage and all of its data. This action cannot be undone.') }}

    +

    {{ _('To confirm, enter the storage name "%(name)s" below.', name=storage.name) | safe }}

    +
    + {{ delete_form.csrf_token(id=False) }} + {{ delete_form.name( + id='delete-storage-name', + value=storage.name + ) }} + {{ delete_form.confirm( + class_="w-full", + placeholder=_('Type the storage name to confirm'), + **{'x-model': 'confirm'} + ) }} +
    + + {{ delete_form.submit( + class_="btn-destructive", + **{':disabled': "confirm !== '" + storage.name + "'"} + ) }} +
    +
    +
    + {% endcall %} + {% else %} +
    +
    + {% include "icons/info.svg" %} +

    {{ _("Only team owners and admins can delete storage.") }}

    +
    +
    + {% endif %} +
    +
    diff --git a/app/templates/team/partials/_storage-general.html b/app/templates/team/partials/_storage-general.html new file mode 100644 index 0000000..8a012b6 --- /dev/null +++ b/app/templates/team/partials/_storage-general.html @@ -0,0 +1,41 @@ +{% from "macros/icon-avatar.html" import icon_avatar %} + +
    +
    +

    {{ _('General') }}

    +

    {{ _('Basic details for this storage.') }}

    +
    + +
    +
    + +
    + {{ icon_avatar(type=storage.type, size="xs") }} + {{ _(storage.type|capitalize) }} +
    +
    + +
    + + +
    + +
    + +
    + + {{ copy(value=storage.id, class="absolute right-1.5 top-1/2 -translate-y-1/2 size-6") }} +
    +
    +
    +
    diff --git a/app/templates/team/partials/_storage-list.html b/app/templates/team/partials/_storage-list.html new file mode 100644 index 0000000..cbbe7d3 --- /dev/null +++ b/app/templates/team/partials/_storage-list.html @@ -0,0 +1,54 @@ +{% from "macros/icon-avatar.html" import icon_avatar %} + +{% set current_type = storage_type or "all" %} + +{% if storages %} + +{% else %} +
    +
    +
    +

    {{ _('No results found.') }}

    +

    {{ _('Try different search terms or filters.') }}

    + + {{ _('Reset filters') }} + +
    +
    +
    +{% endif %} diff --git a/app/templates/team/partials/_storage-project-environment-select.html b/app/templates/team/partials/_storage-project-environment-select.html new file mode 100644 index 0000000..21cbc21 --- /dev/null +++ b/app/templates/team/partials/_storage-project-environment-select.html @@ -0,0 +1,21 @@ +{# {% from "macros/select.html" import select %} +{% from "project/macros/environment-label.html" import environment_label %} + +{% set items = [] %} +{% set items = items.append({"label": _("All environments"), "value": ""}) %} +{% if project %} + {% for environment in project.active_environments %} + {% set items = items.append({ + "label": environment_label(environment), + "value": environment.id + }) %} + {% endfor %} +{% endif %} + +{{ select( + name="environment_id", + selected=selected_environment_id, + items=items, + main_attrs={"class": "w-full"}, + trigger_attrs={"class": "w-full"} +) }} #} diff --git a/app/templates/team/partials/_storage-select-environments.html b/app/templates/team/partials/_storage-select-environments.html new file mode 100644 index 0000000..9c05e41 --- /dev/null +++ b/app/templates/team/partials/_storage-select-environments.html @@ -0,0 +1,35 @@ +{% from "macros/avatar.html" import avatar with context %} +{% from "macros/select.html" import select %} + +{% set items=[] %} +{% if selected_project %} + {% for environment in selected_project.active_environments %} + {% set label %} + {{ environment_label(environment) }} + {% endset %} + {% set items = items.append({ + "label": label, + "value": environment.id + }) %} + {% endfor %} +{% endif %} + +{{ select( + id="storage-environment-select", + name="environment_ids", + selected=selected_environment_ids, + is_combobox=true, + multiple=true, + placeholder=_("All environments"), + items=items, + main_attrs={"class": "w-full"}, + trigger_attrs={ + "aria-invalid": "true" if association_form.environment_ids.errors else "", + "class": "w-full truncate" + } +) }} +{% if is_active %} + {% for error in association_form.environment_ids.errors %} +

    {{ error }}

    + {% endfor %} +{% endif %} diff --git a/app/templates/team/partials/_storage-status.html b/app/templates/team/partials/_storage-status.html new file mode 100644 index 0000000..3e10b20 --- /dev/null +++ b/app/templates/team/partials/_storage-status.html @@ -0,0 +1,16 @@ +{% if storage.error %} + + {% include "icons/circle-x.svg" %} + {{ _('Error') }} + +{% elif storage.status == "pending" %} + + {% include "icons/loader.svg" %} + {{ _('Provisioning') }} + +{% endif %} \ No newline at end of file diff --git a/app/workers/arq.py b/app/workers/arq.py index a541ca4..8941c7a 100644 --- a/app/workers/arq.py +++ b/app/workers/arq.py @@ -1,12 +1,15 @@ import logging from arq.connections import RedisSettings -from workers.tasks.deploy import deploy_start, deploy_finalize, deploy_fail -from workers.tasks.cleanup import ( - cleanup_user, - cleanup_team, - cleanup_project, - cleanup_inactive_deployments, +from workers.tasks.deployment import ( + start_deployment, + finalize_deployment, + fail_deployment, + cleanup_inactive_containers, ) +from workers.tasks.project import delete_project +from workers.tasks.storage import provision_storage, deprovision_storage +from workers.tasks.team import delete_team +from workers.tasks.user import delete_user from config import get_settings @@ -17,13 +20,15 @@ class WorkerSettings: functions = [ - deploy_start, - deploy_finalize, - deploy_fail, - cleanup_user, - cleanup_team, - cleanup_project, - cleanup_inactive_deployments, + start_deployment, + finalize_deployment, + fail_deployment, + delete_user, + delete_team, + delete_project, + cleanup_inactive_containers, + provision_storage, + deprovision_storage, ] redis_settings = RedisSettings.from_dsn(settings.redis_url) max_jobs = 8 diff --git a/app/workers/monitor.py b/app/workers/monitor.py index fda10e4..dbd33d0 100644 --- a/app/workers/monitor.py +++ b/app/workers/monitor.py @@ -50,7 +50,7 @@ async def _check_status( ) if (now_utc - created_at).total_seconds() > settings.deployment_timeout: await redis_pool.enqueue_job( - "deploy_fail", deployment.id, "Deployment timeout" + "fail_deployment", deployment.id, "Deployment timeout" ) logger.warning(f"{log_prefix} Deployment timed out; failure job enqueued.") await _cleanup_deployment(deployment.id) @@ -67,7 +67,7 @@ async def _check_status( } except Exception: await redis_pool.enqueue_job( - "deploy_fail", deployment.id, "Container not found" + "fail_deployment", deployment.id, "Container not found" ) return else: @@ -83,7 +83,7 @@ async def _check_status( if status == "exited": exit_code = container_info["State"].get("ExitCode", -1) reason = f"Container exited with code {exit_code}" - await redis_pool.enqueue_job("deploy_fail", deployment.id, reason) + await redis_pool.enqueue_job("fail_deployment", deployment.id, reason) logger.warning( f"{log_prefix} Deployment failed (failure job enqueued): {reason}" ) @@ -93,7 +93,7 @@ async def _check_status( networks = container_info.get("NetworkSettings", {}).get("Networks", {}) container_ip = networks.get("devpush_runner", {}).get("IPAddress") if container_ip and await _http_probe(container_ip, 8000): - await redis_pool.enqueue_job("deploy_finalize", deployment.id) + await redis_pool.enqueue_job("finalize_deployment", deployment.id) logger.info( f"{log_prefix} Deployment ready (finalization job enqueued)." ) @@ -103,7 +103,7 @@ async def _check_status( logger.error( f"{log_prefix} Unexpected error while checking status.", exc_info=True ) - await redis_pool.enqueue_job("deploy_fail", deployment.id, str(e)) + await redis_pool.enqueue_job("fail_deployment", deployment.id, str(e)) await _cleanup_deployment(deployment.id) finally: if deployment.id in deployment_probe_state: diff --git a/app/workers/tasks/cleanup.py b/app/workers/tasks/cleanup.py deleted file mode 100644 index 1ed127c..0000000 --- a/app/workers/tasks/cleanup.py +++ /dev/null @@ -1,448 +0,0 @@ -import os -import time -import asyncio -from sqlalchemy import select, delete, true, update -import aiodocker -import logging - -from models import ( - Project, - Deployment, - Domain, - Alias, - Team, - TeamMember, - TeamInvite, - User, - UserIdentity, -) -from config import get_settings -from db import AsyncSessionLocal - -logger = logging.getLogger(__name__) - - -async def cleanup_user(ctx, user_id: int): - """Delete a user and all their related resources.""" - logger.info(f"[CleanupUser:{user_id}] Starting cleanup for user") - - async with AsyncSessionLocal() as db: - try: - # Get the user - user_result = await db.execute(select(User).where(User.id == user_id)) - user = user_result.scalar_one_or_none() - if not user: - logger.error(f"[CleanupUser:{user_id}] User not found") - return - - # Find all teams the user is a member of - member_of_result = await db.execute( - select(TeamMember.team_id).where(TeamMember.user_id == user_id) - ) - member_of_team_ids = member_of_result.scalars().all() - - teams_to_delete = [] - for team_id in member_of_team_ids: - # Check if the user is the sole owner of this team - owners_result = await db.execute( - select(TeamMember.user_id).where( - TeamMember.team_id == team_id, TeamMember.role == "owner" - ) - ) - owners = owners_result.scalars().all() - if len(owners) == 1 and owners[0] == user_id: - teams_to_delete.append(team_id) - else: - logger.info( - f"[CleanupUser:{user_id}] Skipping team {team_id} as user is not the sole owner" - ) - - # Cleanup teams that would be left ownerless - for team_id in teams_to_delete: - logger.info( - f"[CleanupUser:{user_id}] Deleting team {team_id} as user is sole owner" - ) - await cleanup_team(ctx, team_id) - - # Clear default team for any other user pointing to a deleted team - if teams_to_delete: - await db.execute( - update(User) - .where(User.default_team_id.in_(teams_to_delete)) - .values(default_team_id=None) - ) - - # Cleanup remaining user data - logger.info(f"[CleanupUser:{user_id}] Deleting remaining user data") - await db.execute(delete(TeamMember).where(TeamMember.user_id == user_id)) - await db.execute(delete(TeamInvite).where(TeamInvite.inviter_id == user_id)) - await db.execute(delete(TeamInvite).where(TeamInvite.email == user.email)) - await db.execute( - delete(UserIdentity).where(UserIdentity.user_id == user_id) - ) - - # Finally, delete the user - logger.info(f"[CleanupUser:{user_id}] Deleting user record") - await db.execute(delete(User).where(User.id == user_id)) - - await db.commit() - logger.info(f"[CleanupUser:{user_id}] Successfully cleaned up user") - - except Exception as e: - logger.error(f"[CleanupUser:{user_id}] Task failed: {e}", exc_info=True) - await db.rollback() - raise - - -async def cleanup_team(ctx, team_id: str): - """Delete a team and related resources (e.g. projects, deployments, aliases) in batches.""" - logger.info(f"[CleanupTeam:{team_id}] Starting cleanup for team") - - async with AsyncSessionLocal() as db: - try: - # Get the team and all its projects - team_result = await db.execute(select(Team).where(Team.id == team_id)) - team = team_result.scalar_one_or_none() - - if not team: - logger.error(f"[CleanupTeam:{team_id}] Team not found") - return - - projects_result = await db.execute( - select(Project).where(Project.team_id == team_id) - ) - projects = projects_result.scalars().all() - - # Sequentially clean up each project - for project in projects: - logger.info( - f"[CleanupTeam:{team_id}] Deleting project {project.id} ('{project.name}')" - ) - project.status = "deleted" - await db.commit() - await cleanup_project(ctx, project.id) - - # Delete related team data - logger.info( - f"[CleanupTeam:{team_id}] Deleting associated team members and invites" - ) - await db.execute(delete(TeamMember).where(TeamMember.team_id == team_id)) - await db.execute(delete(TeamInvite).where(TeamInvite.team_id == team_id)) - - # Clear default team for any other user pointing to this team - await db.execute( - update(User) - .where(User.default_team_id == team_id) - .values(default_team_id=None) - ) - - # Delete the team itself - logger.info(f"[CleanupTeam:{team_id}] Deleting team record") - await db.execute(delete(Team).where(Team.id == team_id)) - - await db.commit() - logger.info(f"[CleanupTeam:{team_id}] Successfully cleaned up team") - - except Exception as e: - logger.error(f"[CleanupTeam:{team_id}] Task failed: {e}", exc_info=True) - await db.rollback() - raise - - -async def cleanup_project(ctx, project_id: str, batch_size: int = 100): - """Delete a project and related resources (e.g. containers, aliases, deployments) in batches.""" - settings = get_settings() - - async with AsyncSessionLocal() as db: - async with aiodocker.Docker(url=settings.docker_host) as docker_client: - try: - project_result = await db.execute( - select(Project).where(Project.id == project_id) - ) - project = project_result.scalar_one_or_none() - - if not project: - logger.error(f"[CleanupProject:{project_id}] Project not found") - raise Exception(f"Project {project_id} not found") - - if project.status != "deleted": - logger.error( - f"[CleanupProject:{project_id}] Project is not marked as deleted" - ) - raise Exception(f"Project {project_id} is not marked as deleted") - - logger.info( - f'[CleanupProject:{project_id}] Starting cleanup for project "{project.name}"' - ) - start_time = time.time() - total_deployments = 0 - total_aliases = 0 - total_containers = 0 - - while True: - # Get a batch of deployments - deployments_result = await db.execute( - select(Deployment) - .where(Deployment.project_id == project_id) - .limit(batch_size) - ) - deployments = deployments_result.scalars().all() - - if not deployments: - logger.info( - f"[CleanupProject:{project_id}] No more deployments to process" - ) - break - - deployment_ids = [deployment.id for deployment in deployments] - - # Remove containers - for deployment in deployments: - if deployment.container_id: - try: - container = await docker_client.containers.get( - deployment.container_id - ) - await container.delete(force=True) - total_containers += 1 - logger.debug( - f"[CleanupProject:{project_id}] Removed container {deployment.container_id}" - ) - except aiodocker.DockerError as e: - if e.status == 404: - logger.warning( - f"[CleanupProject:{project_id}] Container {deployment.container_id} not found" - ) - else: - logger.error( - f"[CleanupProject:{project_id}] Failed to remove container {deployment.container_id}: {e}" - ) - except Exception as e: - logger.error( - f"[CleanupProject:{project_id}] Failed to remove container {deployment.container_id}: {e}" - ) - - try: - # Delete aliases - aliases_deleted_result = await db.execute( - delete(Alias).where(Alias.deployment_id.in_(deployment_ids)) - ) - total_aliases += aliases_deleted_result.rowcount - - # Delete deployments - deployments_deleted_result = await db.execute( - delete(Deployment).where(Deployment.id.in_(deployment_ids)) - ) - total_deployments += deployments_deleted_result.rowcount - - await db.commit() - logger.info( - f"[CleanupProject:{project_id}] Processed batch of {len(deployment_ids)} deployments" - ) - - except Exception as e: - logger.error( - f"[CleanupProject:{project_id}] Failed to commit batch: {e}" - ) - await db.rollback() - await asyncio.sleep(1) - continue - - # No more deployments: - # 1. Remove Traefik config file - project_config_file_path = os.path.join( - settings.traefik_dir, f"project_{project_id}.yml" - ) - if os.path.exists(project_config_file_path): - try: - os.remove(project_config_file_path) - logger.info( - f"[CleanupProject:{project_id}] Removed Traefik config file" - ) - except Exception as e: - logger.error( - f"[CleanupProject:{project_id}] Failed to remove Traefik config: {e}" - ) - - # 2. Delete domains associated with this project - try: - domains_deleted_result = await db.execute( - delete(Domain).where(Domain.project_id == project_id) - ) - total_domains = domains_deleted_result.rowcount - logger.info( - f"[CleanupProject:{project_id}] Removed {total_domains} domains" - ) - except Exception as e: - logger.error( - f"[CleanupProject:{project_id}] Failed to delete domains: {e}" - ) - await db.rollback() - raise - - # 3. Delete the project - try: - await db.execute(delete(Project).where(Project.id == project_id)) - await db.commit() - - duration = time.time() - start_time - logger.info( - f"[CleanupProject:{project_id}] Completed cleanup for {project.name} in {duration:.2f}s:\n" - f"- {total_deployments} deployments removed\n" - f"- {total_aliases} aliases removed\n" - f"- {total_containers} containers removed" - ) - except Exception as e: - logger.error( - f"[CleanupProject:{project_id}] Failed to delete project: {e}" - ) - await db.rollback() - raise - - except Exception as e: - logger.error(f"[CleanupProject:{project_id}] Task failed: {e}") - await db.rollback() - raise - - -async def cleanup_inactive_deployments( - ctx, project_id: str, remove_containers: bool = True -): - """Stop/remove containers for deployments no longer referenced by aliases.""" - settings = get_settings() - - async with AsyncSessionLocal() as db: - async with aiodocker.Docker(url=settings.docker_host) as docker_client: - try: - # Get project - result = await db.execute( - select(Project).where(Project.id == project_id) - ) - project = result.scalar_one_or_none() - - if not project: - logger.warning( - f"[InactiveDeploymentsCleanup:{project_id}] Project not found" - ) - return - - if project.status == "deleted": - logger.info( - f"[InactiveDeploymentsCleanup:{project_id}] Project deleted, skipping" - ) - return - - logger.info( - f"[InactiveDeploymentsCleanup:{project_id}] Starting cleanup for {project.name}" - ) - - # Get active deployment IDs - active_result = await db.execute( - select(Alias.deployment_id) - .join(Deployment, Alias.deployment_id == Deployment.id) - .where( - Deployment.project_id == project_id, - Alias.deployment_id.isnot(None), - ) - .union( - select(Alias.previous_deployment_id) - .join(Deployment, Alias.previous_deployment_id == Deployment.id) - .where( - Deployment.project_id == project_id, - Alias.previous_deployment_id.isnot(None), - ) - ) - ) - active_deployment_ids = set(active_result.scalars().all()) - - logger.debug( - f"[InactiveDeploymentsCleanup:{project_id}] Active deployments: {active_deployment_ids}" - ) - - # Get inactive deployments with containers - inactive_result = await db.execute( - select(Deployment).where( - Deployment.project_id == project_id, - Deployment.container_id.isnot(None), - Deployment.container_status == "running", - Deployment.status == "completed", - Deployment.id.notin_(active_deployment_ids) - if active_deployment_ids - else true(), - ) - ) - inactive_deployments = inactive_result.scalars().all() - - stopped_count = 0 - removed_count = 0 - - for deployment in inactive_deployments: - logger.info( - f"[InactiveDeploymentsCleanup:{project_id}] Processing inactive deployment {deployment.id}" - ) - try: - if deployment.container_id is None: - logger.warning( - f"[InactiveDeploymentsCleanup:{project_id}] Deployment {deployment.id} has no container" - ) - continue - - container = await docker_client.containers.get( - deployment.container_id - ) - - # Stop container - await container.stop() - deployment.container_status = "stopped" - stopped_count += 1 - logger.info( - f"[InactiveDeploymentsCleanup:{project_id}] Stopped container {deployment.container_id}" - ) - - # Remove if requested - if remove_containers: - await container.delete() - deployment.container_status = "removed" - removed_count += 1 - logger.info( - f"[InactiveDeploymentsCleanup:{project_id}] Removed container {deployment.container_id}" - ) - - except aiodocker.DockerError as error: - if error.status == 404: - logger.warning( - f"[InactiveDeploymentsCleanup:{project_id}] Container {deployment.container_id} not found" - ) - deployment.container_status = None - else: - logger.error( - f"[InactiveDeploymentsCleanup:{project_id}] Docker error: {error}" - ) - except Exception as error: - logger.error( - f"[InactiveDeploymentsCleanup:{project_id}] Error processing container: {error}" - ) - - # Commit status updates - if stopped_count > 0 or removed_count > 0: - try: - await db.commit() - logger.info( - f"[InactiveDeploymentsCleanup:{project_id}] Stopped: {stopped_count}, Removed: {removed_count}" - ) - except Exception as e: - logger.error( - f"[InactiveDeploymentsCleanup:{project_id}] Failed to commit: {e}" - ) - await db.rollback() - else: - logger.info( - f"[InactiveDeploymentsCleanup:{project_id}] No inactive containers found" - ) - - except Exception as error: - logger.error( - f"[InactiveDeploymentsCleanup:{project_id}] Task failed: {error}" - ) - await db.rollback() - raise diff --git a/app/workers/tasks/deploy.py b/app/workers/tasks/deployment.py similarity index 71% rename from app/workers/tasks/deploy.py rename to app/workers/tasks/deployment.py index deec920..c22709c 100644 --- a/app/workers/tasks/deploy.py +++ b/app/workers/tasks/deployment.py @@ -2,12 +2,12 @@ from datetime import datetime, timezone import aiodocker import logging -from sqlalchemy import select +from sqlalchemy import select, true from sqlalchemy.orm import joinedload from typing import Any import shlex -from models import Deployment, Project +from models import Alias, Deployment, Project from db import AsyncSessionLocal from dependencies import ( get_redis_client, @@ -32,7 +32,7 @@ async def _log_to_container(container, message, error=False): await exec.start(detach=True) -async def deploy_start(ctx, deployment_id: str): +async def start_deployment(ctx, deployment_id: str): """Starts a deployment.""" try: settings = get_settings() @@ -46,9 +46,7 @@ async def deploy_start(ctx, deployment_id: str): deployment = ( await db.execute( select(Deployment) - .options( - joinedload(Deployment.project).joinedload(Project.team) - ) + .options(joinedload(Deployment.project).joinedload(Project.team)) .where(Deployment.id == deployment_id) ) ).scalar_one() @@ -83,9 +81,12 @@ async def deploy_start(ctx, deployment_id: str): ) # Prepare environment variables - env_vars_dict = DeploymentService().build_runtime_env_vars( + env_vars_dict = DeploymentService().get_runtime_env_vars( deployment, settings ) + mounts = await DeploymentService().get_runtime_mounts( + deployment, db, settings + ) # Prepare commands commands = [] @@ -227,6 +228,7 @@ async def deploy_start(ctx, deployment_id: str): if memory_mb is not None and memory_mb > 0 else {} ), + **({"Binds": mounts} if mounts else {}), "SecurityOpt": ["no-new-privileges:true"], "LogConfig": { "Type": "json-file", @@ -274,11 +276,11 @@ async def deploy_start(ctx, deployment_id: str): except Exception as e: job_queue: ArqRedis = ctx["redis"] - await job_queue.enqueue_job("deploy_fail", deployment_id, reason=str(e)) + await job_queue.enqueue_job("fail_deployment", deployment_id, reason=str(e)) logger.info(f"{log_prefix} Deployment startup failed.", exc_info=True) -async def deploy_finalize(ctx, deployment_id: str): +async def finalize_deployment(ctx, deployment_id: str): """Finalizes a deployment, setting up aliases and updating Traefik config.""" settings = get_settings() redis_client = get_redis_client() @@ -326,7 +328,7 @@ async def deploy_finalize(ctx, deployment_id: str): # Cleanup inactive deployments job_queue: ArqRedis = ctx["redis"] await job_queue.enqueue_job( - "cleanup_inactive_deployments", deployment.project_id + "cleanup_inactive_containers", deployment.project_id ) logger.info( f"{log_prefix} Inactive deployments cleanup job queued for project {deployment.project_id}." @@ -353,7 +355,7 @@ async def deploy_finalize(ctx, deployment_id: str): logger.error(f"{log_prefix} Error finalizing deployment.", exc_info=True) -async def deploy_fail(ctx, deployment_id: str, reason: str = None): +async def fail_deployment(ctx, deployment_id: str, reason: str = None): """Handles a failed deployment, cleaning up resources.""" log_prefix = f"[DeployFail:{deployment_id}]" logger.info(f"{log_prefix} Handling failed deployment. Reason: {reason}") @@ -428,3 +430,146 @@ async def deploy_fail(ctx, deployment_id: str, reason: str = None): f"stream:project:{deployment.project_id}:updates", fields ) logger.error(f"{log_prefix} Deployment failed and cleaned up.") + + +async def cleanup_inactive_containers( + ctx, project_id: str, remove_containers: bool = True +): + """Stop/remove containers for deployments no longer referenced by aliases.""" + settings = get_settings() + + async with AsyncSessionLocal() as db: + async with aiodocker.Docker(url=settings.docker_host) as docker_client: + try: + # Get project + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + + if not project: + logger.warning( + f"[CleanupInactiveContainers:{project_id}] Project not found" + ) + return + + if project.status == "deleted": + logger.info( + f"[CleanupInactiveContainers:{project_id}] Project deleted, skipping" + ) + return + + logger.info( + f"[CleanupInactiveContainers:{project_id}] Starting cleanup for {project.name}" + ) + + # Get active deployment IDs + active_result = await db.execute( + select(Alias.deployment_id) + .join(Deployment, Alias.deployment_id == Deployment.id) + .where( + Deployment.project_id == project_id, + Alias.deployment_id.isnot(None), + ) + .union( + select(Alias.previous_deployment_id) + .join(Deployment, Alias.previous_deployment_id == Deployment.id) + .where( + Deployment.project_id == project_id, + Alias.previous_deployment_id.isnot(None), + ) + ) + ) + active_deployment_ids = set(active_result.scalars().all()) + + logger.debug( + f"[CleanupInactiveContainers:{project_id}] Active deployments: {active_deployment_ids}" + ) + + # Get inactive deployments with containers + inactive_result = await db.execute( + select(Deployment).where( + Deployment.project_id == project_id, + Deployment.container_id.isnot(None), + Deployment.container_status == "running", + Deployment.status == "completed", + Deployment.id.notin_(active_deployment_ids) + if active_deployment_ids + else true(), + ) + ) + inactive_deployments = inactive_result.scalars().all() + + stopped_count = 0 + removed_count = 0 + + for deployment in inactive_deployments: + logger.info( + f"[CleanupInactiveContainers:{project_id}] Processing inactive deployment {deployment.id}" + ) + try: + if deployment.container_id is None: + logger.warning( + f"[CleanupInactiveContainers:{project_id}] Deployment {deployment.id} has no container" + ) + continue + + container = await docker_client.containers.get( + deployment.container_id + ) + + # Stop container + await container.stop() + deployment.container_status = "stopped" + stopped_count += 1 + logger.info( + f"[CleanupInactiveContainers:{project_id}] Stopped container {deployment.container_id}" + ) + + # Remove if requested + if remove_containers: + await container.delete() + deployment.container_status = "removed" + removed_count += 1 + logger.info( + f"[CleanupInactiveContainers:{project_id}] Removed container {deployment.container_id}" + ) + + except aiodocker.DockerError as error: + if error.status == 404: + logger.warning( + f"[CleanupInactiveContainers:{project_id}] Container {deployment.container_id} not found" + ) + deployment.container_status = None + else: + logger.error( + f"[CleanupInactiveContainers:{project_id}] Docker error: {error}" + ) + except Exception as error: + logger.error( + f"[CleanupInactiveContainers:{project_id}] Error processing container: {error}" + ) + + # Commit status updates + if stopped_count > 0 or removed_count > 0: + try: + await db.commit() + logger.info( + f"[CleanupInactiveContainers:{project_id}] Stopped: {stopped_count}, Removed: {removed_count}" + ) + except Exception as e: + logger.error( + f"[CleanupInactiveContainers:{project_id}] Failed to commit: {e}" + ) + await db.rollback() + else: + logger.info( + f"[CleanupInactiveContainers:{project_id}] No inactive containers found" + ) + + except Exception as error: + logger.error( + f"[CleanupInactiveContainers:{project_id}] Task failed: {error}" + ) + await db.rollback() + raise diff --git a/app/workers/tasks/project.py b/app/workers/tasks/project.py new file mode 100644 index 0000000..d208afe --- /dev/null +++ b/app/workers/tasks/project.py @@ -0,0 +1,187 @@ +import asyncio +import logging +import os +import time + +import aiodocker +from sqlalchemy import select, delete + +from config import get_settings +from db import AsyncSessionLocal +from models import Alias, Deployment, Domain, Project, StorageProject + +logger = logging.getLogger(__name__) + + +async def delete_project(ctx, project_id: str, batch_size: int = 100): + """Delete a project and related resources (e.g. containers, aliases, deployments) in batches.""" + settings = get_settings() + + async with AsyncSessionLocal() as db: + async with aiodocker.Docker(url=settings.docker_host) as docker_client: + try: + project_result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = project_result.scalar_one_or_none() + + if not project: + logger.error(f"[DeleteProject:{project_id}] Project not found") + raise Exception(f"Project {project_id} not found") + + if project.status != "deleted": + logger.error( + f"[DeleteProject:{project_id}] Project is not marked as deleted" + ) + raise Exception(f"Project {project_id} is not marked as deleted") + + logger.info( + f'[DeleteProject:{project_id}] Starting delete for project "{project.name}"' + ) + start_time = time.time() + total_deployments = 0 + total_aliases = 0 + total_containers = 0 + + while True: + # Get a batch of deployments + deployments_result = await db.execute( + select(Deployment) + .where(Deployment.project_id == project_id) + .limit(batch_size) + ) + deployments = deployments_result.scalars().all() + + if not deployments: + logger.info( + f"[DeleteProject:{project_id}] No more deployments to process" + ) + break + + deployment_ids = [deployment.id for deployment in deployments] + + # Remove containers + for deployment in deployments: + if deployment.container_id: + try: + container = await docker_client.containers.get( + deployment.container_id + ) + await container.delete(force=True) + total_containers += 1 + logger.debug( + f"[DeleteProject:{project_id}] Removed container {deployment.container_id}" + ) + except aiodocker.DockerError as e: + if e.status == 404: + logger.warning( + f"[DeleteProject:{project_id}] Container {deployment.container_id} not found" + ) + else: + logger.error( + f"[DeleteProject:{project_id}] Failed to remove container {deployment.container_id}: {e}" + ) + except Exception as e: + logger.error( + f"[DeleteProject:{project_id}] Failed to remove container {deployment.container_id}: {e}" + ) + + try: + # Delete aliases + aliases_deleted_result = await db.execute( + delete(Alias).where(Alias.deployment_id.in_(deployment_ids)) + ) + total_aliases += aliases_deleted_result.rowcount + + # Delete deployments + deployments_deleted_result = await db.execute( + delete(Deployment).where(Deployment.id.in_(deployment_ids)) + ) + total_deployments += deployments_deleted_result.rowcount + + await db.commit() + logger.info( + f"[DeleteProject:{project_id}] Processed batch of {len(deployment_ids)} deployments" + ) + + except Exception as e: + logger.error( + f"[DeleteProject:{project_id}] Failed to commit batch: {e}" + ) + await db.rollback() + await asyncio.sleep(1) + continue + + # No more deployments: + # 1. Remove Traefik config file + project_config_file_path = os.path.join( + settings.traefik_dir, f"project_{project_id}.yml" + ) + if os.path.exists(project_config_file_path): + try: + os.remove(project_config_file_path) + logger.info( + f"[DeleteProject:{project_id}] Removed Traefik config file" + ) + except Exception as e: + logger.error( + f"[DeleteProject:{project_id}] Failed to remove Traefik config: {e}" + ) + + # 2. Delete domains associated with this project + try: + domains_deleted_result = await db.execute( + delete(Domain).where(Domain.project_id == project_id) + ) + total_domains = domains_deleted_result.rowcount + logger.info( + f"[DeleteProject:{project_id}] Removed {total_domains} domains" + ) + except Exception as e: + logger.error( + f"[DeleteProject:{project_id}] Failed to delete domains: {e}" + ) + await db.rollback() + raise + + # 3. Delete storage associations for this project + try: + storage_links_deleted_result = await db.execute( + delete(StorageProject).where( + StorageProject.project_id == project_id + ) + ) + total_storage_links = storage_links_deleted_result.rowcount + logger.info( + f"[DeleteProject:{project_id}] Removed {total_storage_links} storage associations" + ) + except Exception as e: + logger.error( + f"[DeleteProject:{project_id}] Failed to delete storage associations: {e}" + ) + await db.rollback() + raise + + # 4. Delete the project + try: + await db.execute(delete(Project).where(Project.id == project_id)) + await db.commit() + + duration = time.time() - start_time + logger.info( + f"[DeleteProject:{project_id}] Completed delete for {project.name} in {duration:.2f}s:\n" + f"- {total_deployments} deployments removed\n" + f"- {total_aliases} aliases removed\n" + f"- {total_containers} containers removed" + ) + except Exception as e: + logger.error( + f"[DeleteProject:{project_id}] Failed to delete project: {e}" + ) + await db.rollback() + raise + + except Exception as e: + logger.error(f"[DeleteProject:{project_id}] Task failed: {e}") + await db.rollback() + raise diff --git a/app/workers/tasks/storage.py b/app/workers/tasks/storage.py new file mode 100644 index 0000000..e6193cb --- /dev/null +++ b/app/workers/tasks/storage.py @@ -0,0 +1,151 @@ +import asyncio +import logging +import shutil +import sqlite3 +from pathlib import Path + +from sqlalchemy import select, delete + +from config import get_settings +from db import AsyncSessionLocal +from models import Storage, StorageProject, utc_now + +logger = logging.getLogger(__name__) + + +async def provision_storage(ctx, resource_id: str): + log_prefix = f"[ProvisionStorage:{resource_id}]" + logger.info(f"{log_prefix} Starting storage provisioning") + settings = get_settings() + + async with AsyncSessionLocal() as db: + storage = ( + await db.execute(select(Storage).where(Storage.id == resource_id)) + ).scalar_one_or_none() + if not storage: + logger.error(f"{log_prefix} Storage not found") + return + + try: + if storage.type == "database": + await asyncio.to_thread(_ensure_database_path, settings, storage) + elif storage.type == "volume": + await asyncio.to_thread(_ensure_volume_path, settings, storage) + else: + logger.error( + f"{log_prefix} Unsupported storage type: {storage.type}" + ) + return + + storage.status = "active" + storage.error = None + storage.updated_at = utc_now() + await db.commit() + logger.info(f"{log_prefix} Storage provisioned") + except Exception as exc: + storage.error = { + "stage": f"provision_{storage.type}", + "message": str(exc), + "last_attempt_at": utc_now().isoformat(), + } + storage.updated_at = utc_now() + await db.commit() + logger.error(f"{log_prefix} Provisioning failed: {exc}", exc_info=True) + raise + + +async def deprovision_storage(ctx, resource_id: str): + log_prefix = f"[DeprovisionStorage:{resource_id}]" + logger.info(f"{log_prefix} Starting storage deprovisioning") + settings = get_settings() + + async with AsyncSessionLocal() as db: + storage = ( + await db.execute(select(Storage).where(Storage.id == resource_id)) + ).scalar_one_or_none() + if not storage: + logger.error(f"{log_prefix} Storage not found") + return + + try: + if storage.type == "database": + await asyncio.to_thread(_remove_database_path, settings, storage) + elif storage.type == "volume": + await asyncio.to_thread(_remove_volume_path, settings, storage) + else: + logger.error( + f"{log_prefix} Unsupported storage type: {storage.type}" + ) + return + + await db.execute( + delete(StorageProject).where( + StorageProject.storage_id == storage.id + ) + ) + await db.execute(delete(Storage).where(Storage.id == storage.id)) + await db.commit() + logger.info(f"{log_prefix} Storage deprovisioned") + except Exception as exc: + storage.error = { + "stage": f"deprovision_{storage.type}", + "message": str(exc), + "last_attempt_at": utc_now().isoformat(), + } + storage.updated_at = utc_now() + await db.commit() + logger.error(f"{log_prefix} Deprovisioning failed: {exc}", exc_info=True) + raise + + +def _ensure_database_path(settings, storage: Storage) -> None: + base_dir = ( + Path(settings.data_dir) + / "storage" + / storage.team_id + / "database" + / storage.name + ) + db_path = base_dir / "db.sqlite" + + base_dir.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path) + try: + conn.execute("PRAGMA journal_mode=WAL;") + finally: + conn.close() + + +def _ensure_volume_path(settings, storage: Storage) -> None: + base_dir = ( + Path(settings.data_dir) + / "storage" + / storage.team_id + / "volume" + / storage.name + ) + base_dir.mkdir(parents=True, exist_ok=True) + + +def _remove_database_path(settings, storage: Storage) -> None: + base_dir = ( + Path(settings.data_dir) + / "storage" + / storage.team_id + / "database" + / storage.name + ) + if base_dir.exists(): + shutil.rmtree(base_dir) + + +def _remove_volume_path(settings, storage: Storage) -> None: + base_dir = ( + Path(settings.data_dir) + / "storage" + / storage.team_id + / "volume" + / storage.name + ) + if base_dir.exists(): + shutil.rmtree(base_dir) diff --git a/app/workers/tasks/team.py b/app/workers/tasks/team.py new file mode 100644 index 0000000..12e04a6 --- /dev/null +++ b/app/workers/tasks/team.py @@ -0,0 +1,75 @@ +import logging +from sqlalchemy import select, delete, update + +from db import AsyncSessionLocal +from models import Project, Storage, Team, TeamInvite, TeamMember, User +from workers.tasks.project import delete_project +from workers.tasks.storage import deprovision_storage + +logger = logging.getLogger(__name__) + + +async def delete_team(ctx, team_id: str): + """Delete a team and related resources (e.g. projects, deployments, aliases) in batches.""" + logger.info(f"[DeleteTeam:{team_id}] Starting delete for team") + + async with AsyncSessionLocal() as db: + try: + # Get the team and all its projects + team_result = await db.execute(select(Team).where(Team.id == team_id)) + team = team_result.scalar_one_or_none() + + if not team: + logger.error(f"[DeleteTeam:{team_id}] Team not found") + return + + projects_result = await db.execute( + select(Project).where(Project.team_id == team_id) + ) + projects = projects_result.scalars().all() + + # Sequentially clean up each project + for project in projects: + logger.info( + f"[DeleteTeam:{team_id}] Deleting project {project.id} ('{project.name}')" + ) + project.status = "deleted" + await db.commit() + await delete_project(ctx, project.id) + + # Deprovision team storage + storage_ids_result = await db.execute( + select(Storage.id).where(Storage.team_id == team_id) + ) + storage_ids = storage_ids_result.scalars().all() + for storage_id in storage_ids: + logger.info( + f"[DeleteTeam:{team_id}] Deprovisioning storage {storage_id}" + ) + await deprovision_storage(ctx, storage_id) + + # Delete related team data + logger.info( + f"[DeleteTeam:{team_id}] Deleting associated team members and invites" + ) + await db.execute(delete(TeamMember).where(TeamMember.team_id == team_id)) + await db.execute(delete(TeamInvite).where(TeamInvite.team_id == team_id)) + + # Clear default team for any other user pointing to this team + await db.execute( + update(User) + .where(User.default_team_id == team_id) + .values(default_team_id=None) + ) + + # Delete the team itself + logger.info(f"[DeleteTeam:{team_id}] Deleting team record") + await db.execute(delete(Team).where(Team.id == team_id)) + + await db.commit() + logger.info(f"[DeleteTeam:{team_id}] Successfully deleted team") + + except Exception as e: + logger.error(f"[DeleteTeam:{team_id}] Task failed: {e}", exc_info=True) + await db.rollback() + raise diff --git a/app/workers/tasks/user.py b/app/workers/tasks/user.py new file mode 100644 index 0000000..a129af0 --- /dev/null +++ b/app/workers/tasks/user.py @@ -0,0 +1,80 @@ +import logging +from sqlalchemy import select, delete, update + +from db import AsyncSessionLocal +from models import TeamMember, TeamInvite, User, UserIdentity +from workers.tasks.team import delete_team + +logger = logging.getLogger(__name__) + + +async def delete_user(ctx, user_id: int): + """Delete a user and all their related resources.""" + logger.info(f"[DeleteUser:{user_id}] Starting delete for user") + + async with AsyncSessionLocal() as db: + try: + # Get the user + user_result = await db.execute(select(User).where(User.id == user_id)) + user = user_result.scalar_one_or_none() + if not user: + logger.error(f"[DeleteUser:{user_id}] User not found") + return + + # Find all teams the user is a member of + member_of_result = await db.execute( + select(TeamMember.team_id).where(TeamMember.user_id == user_id) + ) + member_of_team_ids = member_of_result.scalars().all() + + teams_to_delete = [] + for team_id in member_of_team_ids: + # Check if the user is the sole owner of this team + owners_result = await db.execute( + select(TeamMember.user_id).where( + TeamMember.team_id == team_id, TeamMember.role == "owner" + ) + ) + owners = owners_result.scalars().all() + if len(owners) == 1 and owners[0] == user_id: + teams_to_delete.append(team_id) + else: + logger.info( + f"[DeleteUser:{user_id}] Skipping team {team_id} as user is not the sole owner" + ) + + # Cleanup teams that would be left ownerless + for team_id in teams_to_delete: + logger.info( + f"[DeleteUser:{user_id}] Deleting team {team_id} as user is sole owner" + ) + await delete_team(ctx, team_id) + + # Clear default team for any other user pointing to a deleted team + if teams_to_delete: + await db.execute( + update(User) + .where(User.default_team_id.in_(teams_to_delete)) + .values(default_team_id=None) + ) + + # Cleanup remaining user data + logger.info(f"[DeleteUser:{user_id}] Deleting remaining user data") + await db.execute(delete(TeamMember).where(TeamMember.user_id == user_id)) + await db.execute(delete(TeamInvite).where(TeamInvite.inviter_id == user_id)) + await db.execute(delete(TeamInvite).where(TeamInvite.email == user.email)) + await db.execute( + delete(UserIdentity).where(UserIdentity.user_id == user_id) + ) + + # Finally, delete the user + logger.info(f"[DeleteUser:{user_id}] Deleting user record") + await db.execute(delete(User).where(User.id == user_id)) + + await db.commit() + logger.info(f"[DeleteUser:{user_id}] Successfully deleted user") + + except Exception as e: + logger.error(f"[DeleteUser:{user_id}] Task failed: {e}", exc_info=True) + await db.rollback() + raise diff --git a/compose/base.yml b/compose/base.yml index 3fb8e3a..572ef9a 100644 --- a/compose/base.yml +++ b/compose/base.yml @@ -16,6 +16,8 @@ services: - internal env_file: - ${DATA_DIR:-../data}/.env + environment: + HOST_DATA_DIR: ${DATA_DIR:-../data} volumes: - ${DATA_DIR:-../data}:/data labels: @@ -26,10 +28,10 @@ services: - "traefik.docker.network=devpush_default" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - interval: 60s - timeout: 10s + interval: 10s + timeout: 5s retries: 3 - start_period: 20s + start_period: 10s worker-arq: build: @@ -50,14 +52,16 @@ services: - runner env_file: - ${DATA_DIR:-../data}/.env + environment: + HOST_DATA_DIR: ${DATA_DIR:-../data} volumes: - - ${DATA_DIR:-../data}/traefik:/data/traefik + - ${DATA_DIR:-../data}:/data healthcheck: test: ["CMD", "uv", "run", "arq", "--check", "workers.arq.WorkerSettings"] - interval: 60s - timeout: 10s + interval: 10s + timeout: 5s retries: 3 - start_period: 20s + start_period: 10s worker-monitor: build: @@ -77,6 +81,8 @@ services: - runner env_file: - ${DATA_DIR:-../data}/.env + environment: + HOST_DATA_DIR: ${DATA_DIR:-../data} redis: image: redis:alpine diff --git a/docker/runner/Dockerfile.bun-1.3 b/docker/runner/Dockerfile.bun-1.3 index bc8fe1a..b1f11f0 100644 --- a/docker/runner/Dockerfile.bun-1.3 +++ b/docker/runner/Dockerfile.bun-1.3 @@ -1,7 +1,22 @@ FROM oven/bun:1.3-slim +ARG APP_UID=1000 +ARG APP_GID=1000 + # Create non-root user -RUN groupadd -r appgroup && useradd -r -g appgroup appuser +RUN set -eu; \ + group_name=appgroup; \ + existing_group="$(getent group "${APP_GID}" | cut -d: -f1 || true)"; \ + if [ -n "$existing_group" ]; then \ + group_name="$existing_group"; \ + else \ + groupadd --system -g "${APP_GID}" appgroup; \ + fi; \ + if id -u appuser >/dev/null 2>&1; then \ + usermod -u "${APP_UID}" -g "$group_name" appuser; \ + else \ + useradd --system -u "${APP_UID}" -g "$group_name" -o appuser; \ + fi # Install system dependencies RUN apt-get update && \ @@ -13,9 +28,9 @@ RUN apt-get update && \ WORKDIR /app # Set permissions and Bun install dir -RUN chown -R appuser:appgroup /app && \ +RUN chown -R appuser:$(id -gn appuser) /app && \ mkdir -p /home/appuser/.bun && \ - chown -R appuser:appgroup /home/appuser + chown -R appuser:$(id -gn appuser) /home/appuser # Switch to non-root user USER appuser @@ -23,4 +38,4 @@ USER appuser # Set Bun environment variables ENV BUN_INSTALL=/home/appuser/.bun ENV PATH=${BUN_INSTALL}/bin:${PATH} -ENV HOME=/home/appuser \ No newline at end of file +ENV HOME=/home/appuser diff --git a/docker/runner/Dockerfile.frankenphp-8.3 b/docker/runner/Dockerfile.frankenphp-8.3 index bc8ee5e..2d570b4 100644 --- a/docker/runner/Dockerfile.frankenphp-8.3 +++ b/docker/runner/Dockerfile.frankenphp-8.3 @@ -1,7 +1,31 @@ FROM ghcr.io/hunvreus/devpush-frankenphp-base:8.3 +HEALTHCHECK NONE + +ARG APP_UID=1000 +ARG APP_GID=1000 + USER root +# Create non-root user +RUN set -eu; \ + group_name=appgroup; \ + existing_group="$(getent group "${APP_GID}" | cut -d: -f1 || true)"; \ + if [ -n "$existing_group" ]; then \ + group_name="$existing_group"; \ + else \ + groupadd --system -g "${APP_GID}" appgroup; \ + fi; \ + if id -u appuser >/dev/null 2>&1; then \ + usermod -u "${APP_UID}" -g "$group_name" appuser; \ + else \ + useradd --system -u "${APP_UID}" -g "$group_name" -o appuser; \ + fi + +RUN install -d -m 0755 -o appuser -g "$(id -gn appuser)" /home/appuser /app + +WORKDIR /app + # Default Caddy config (Laravel-style) RUN install -d -m 0755 /etc/caddy && \ cat > /etc/caddy/Caddyfile <<'EOF' @@ -17,4 +41,5 @@ EOF USER appuser +ENV HOME=/home/appuser ENV XDG_DATA_HOME=/tmp XDG_CONFIG_HOME=/tmp diff --git a/docker/runner/Dockerfile.frankenphp-node-8.3 b/docker/runner/Dockerfile.frankenphp-node-8.3 index 8dbd8a3..a78db36 100644 --- a/docker/runner/Dockerfile.frankenphp-node-8.3 +++ b/docker/runner/Dockerfile.frankenphp-node-8.3 @@ -1,7 +1,31 @@ FROM ghcr.io/hunvreus/devpush-frankenphp-base:8.3 +HEALTHCHECK NONE + +ARG APP_UID=1000 +ARG APP_GID=1000 + USER root +# Create non-root user +RUN set -eu; \ + group_name=appgroup; \ + existing_group="$(getent group "${APP_GID}" | cut -d: -f1 || true)"; \ + if [ -n "$existing_group" ]; then \ + group_name="$existing_group"; \ + else \ + groupadd --system -g "${APP_GID}" appgroup; \ + fi; \ + if id -u appuser >/dev/null 2>&1; then \ + usermod -u "${APP_UID}" -g "$group_name" appuser; \ + else \ + useradd --system -u "${APP_UID}" -g "$group_name" -o appuser; \ + fi + +RUN install -d -m 0755 -o appuser -g "$(id -gn appuser)" /home/appuser /app + +WORKDIR /app + # Install Node.js ARG NODE_MAJOR=22 RUN apt-get update && \ @@ -31,4 +55,5 @@ EOF USER appuser +ENV HOME=/home/appuser ENV XDG_DATA_HOME=/tmp XDG_CONFIG_HOME=/tmp diff --git a/docker/runner/Dockerfile.frankenphp-node-worker-8.3 b/docker/runner/Dockerfile.frankenphp-node-worker-8.3 index 5a5b822..276f8a8 100644 --- a/docker/runner/Dockerfile.frankenphp-node-worker-8.3 +++ b/docker/runner/Dockerfile.frankenphp-node-worker-8.3 @@ -1,7 +1,31 @@ FROM ghcr.io/hunvreus/devpush-frankenphp-base:8.3 +HEALTHCHECK NONE + +ARG APP_UID=1000 +ARG APP_GID=1000 + USER root +# Create non-root user +RUN set -eu; \ + group_name=appgroup; \ + existing_group="$(getent group "${APP_GID}" | cut -d: -f1 || true)"; \ + if [ -n "$existing_group" ]; then \ + group_name="$existing_group"; \ + else \ + groupadd --system -g "${APP_GID}" appgroup; \ + fi; \ + if id -u appuser >/dev/null 2>&1; then \ + usermod -u "${APP_UID}" -g "$group_name" appuser; \ + else \ + useradd --system -u "${APP_UID}" -g "$group_name" -o appuser; \ + fi + +RUN install -d -m 0755 -o appuser -g "$(id -gn appuser)" /home/appuser /app + +WORKDIR /app + # Install Node.js ARG NODE_MAJOR=22 RUN apt-get update && \ @@ -33,4 +57,5 @@ EOF USER appuser +ENV HOME=/home/appuser ENV XDG_DATA_HOME=/tmp XDG_CONFIG_HOME=/tmp diff --git a/docker/runner/Dockerfile.go-1.25 b/docker/runner/Dockerfile.go-1.25 index b94507c..dbf3668 100644 --- a/docker/runner/Dockerfile.go-1.25 +++ b/docker/runner/Dockerfile.go-1.25 @@ -1,7 +1,22 @@ FROM golang:1.25-bookworm +ARG APP_UID=1000 +ARG APP_GID=1000 + # Create non-root user -RUN addgroup --system appgroup && adduser --system --group appuser +RUN set -eu; \ + group_name=appgroup; \ + existing_group="$(getent group "${APP_GID}" | cut -d: -f1 || true)"; \ + if [ -n "$existing_group" ]; then \ + group_name="$existing_group"; \ + else \ + groupadd --system -g "${APP_GID}" appgroup; \ + fi; \ + if id -u appuser >/dev/null 2>&1; then \ + usermod -u "${APP_UID}" -g "$group_name" appuser; \ + else \ + useradd --system -u "${APP_UID}" -g "$group_name" -o appuser; \ + fi # Install system dependencies RUN apt-get update && \ @@ -14,9 +29,9 @@ RUN apt-get update && \ WORKDIR /app # Set permissions -RUN chown -R appuser:appgroup /app && \ +RUN chown -R appuser:$(id -gn appuser) /app && \ mkdir -p /home/appuser/go /home/appuser/.cache/go-build && \ - chown -R appuser:appgroup /home/appuser + chown -R appuser:$(id -gn appuser) /home/appuser # Switch to non-root user USER appuser @@ -26,4 +41,4 @@ ENV GOPATH=/home/appuser/go ENV GOCACHE=/home/appuser/.cache/go-build ENV GOMODCACHE=/home/appuser/go/pkg/mod ENV HOME=/home/appuser -ENV PATH=/home/appuser/go/bin:$PATH \ No newline at end of file +ENV PATH=/home/appuser/go/bin:$PATH diff --git a/docker/runner/Dockerfile.node-20 b/docker/runner/Dockerfile.node-20 index 7d1cffd..11ae882 100644 --- a/docker/runner/Dockerfile.node-20 +++ b/docker/runner/Dockerfile.node-20 @@ -1,7 +1,22 @@ FROM node:20-slim +ARG APP_UID=1000 +ARG APP_GID=1000 + # Create non-root user -RUN addgroup --system appgroup && adduser --system --group appuser +RUN set -eu; \ + group_name=appgroup; \ + existing_group="$(getent group "${APP_GID}" | cut -d: -f1 || true)"; \ + if [ -n "$existing_group" ]; then \ + group_name="$existing_group"; \ + else \ + groupadd --system -g "${APP_GID}" appgroup; \ + fi; \ + if id -u appuser >/dev/null 2>&1; then \ + usermod -u "${APP_UID}" -g "$group_name" appuser; \ + else \ + useradd --system -u "${APP_UID}" -g "$group_name" -o appuser; \ + fi # Install system dependencies RUN apt-get update && \ @@ -16,9 +31,9 @@ RUN npm install -g pnpm WORKDIR /app # Set permissions -RUN chown -R appuser:appgroup /app && \ +RUN chown -R appuser:$(id -gn appuser) /app && \ mkdir -p /home/appuser/.npm && \ - chown -R appuser:appgroup /home/appuser + chown -R appuser:$(id -gn appuser) /home/appuser # Switch to non-root user USER appuser @@ -26,4 +41,4 @@ USER appuser # Set npm to install packages in user's home ENV NPM_CONFIG_PREFIX=/home/appuser/.npm ENV PATH=/home/appuser/.npm/bin:$PATH -ENV HOME=/home/appuser \ No newline at end of file +ENV HOME=/home/appuser diff --git a/docker/runner/Dockerfile.php-fpm-8.3 b/docker/runner/Dockerfile.php-fpm-8.3 index e440027..7f2fab5 100644 --- a/docker/runner/Dockerfile.php-fpm-8.3 +++ b/docker/runner/Dockerfile.php-fpm-8.3 @@ -1 +1,32 @@ FROM ghcr.io/hunvreus/devpush-php-fpm-base:8.3 + +ARG APP_UID=1000 +ARG APP_GID=1000 + +USER root + +# Create non-root user +RUN set -eu; \ + group_name=appgroup; \ + existing_group="$(getent group "${APP_GID}" | cut -d: -f1 || true)"; \ + if [ -n "$existing_group" ]; then \ + group_name="$existing_group"; \ + else \ + groupadd --system -g "${APP_GID}" appgroup; \ + fi; \ + if id -u appuser >/dev/null 2>&1; then \ + usermod -u "${APP_UID}" -g "$group_name" appuser; \ + else \ + useradd --system -u "${APP_UID}" -g "$group_name" -o appuser; \ + fi + +RUN install -d -m 0755 -o appuser -g "$(id -gn appuser)" /home/appuser /app /etc/caddy + +WORKDIR /app + +# Default Caddy config +RUN printf '%s\n' ':8000' 'root * /app/public' 'encode zstd gzip' 'php_fastcgi 127.0.0.1:9000' 'file_server' > /etc/caddy/Caddyfile + +USER appuser + +ENV HOME=/home/appuser diff --git a/docker/runner/Dockerfile.php-fpm-node-8.3 b/docker/runner/Dockerfile.php-fpm-node-8.3 index 534b9d7..47536d7 100644 --- a/docker/runner/Dockerfile.php-fpm-node-8.3 +++ b/docker/runner/Dockerfile.php-fpm-node-8.3 @@ -1,7 +1,32 @@ FROM ghcr.io/hunvreus/devpush-php-fpm-base:8.3 +ARG APP_UID=1000 +ARG APP_GID=1000 + USER root +# Create non-root user +RUN set -eu; \ + group_name=appgroup; \ + existing_group="$(getent group "${APP_GID}" | cut -d: -f1 || true)"; \ + if [ -n "$existing_group" ]; then \ + group_name="$existing_group"; \ + else \ + groupadd --system -g "${APP_GID}" appgroup; \ + fi; \ + if id -u appuser >/dev/null 2>&1; then \ + usermod -u "${APP_UID}" -g "$group_name" appuser; \ + else \ + useradd --system -u "${APP_UID}" -g "$group_name" -o appuser; \ + fi + +RUN install -d -m 0755 -o appuser -g "$(id -gn appuser)" /home/appuser /app /etc/caddy + +WORKDIR /app + +# Default Caddy config +RUN printf '%s\n' ':8000' 'root * /app/public' 'encode zstd gzip' 'php_fastcgi 127.0.0.1:9000' 'file_server' > /etc/caddy/Caddyfile + # Install Node.js ARG NODE_MAJOR=22 RUN apt-get update && \ @@ -17,3 +42,5 @@ RUN apt-get update && \ && rm -rf /var/lib/apt/lists/* USER appuser + +ENV HOME=/home/appuser diff --git a/docker/runner/Dockerfile.pypy-3.11 b/docker/runner/Dockerfile.pypy-3.11 index 16732a9..b636c86 100644 --- a/docker/runner/Dockerfile.pypy-3.11 +++ b/docker/runner/Dockerfile.pypy-3.11 @@ -1,7 +1,22 @@ FROM pypy:3.11-slim +ARG APP_UID=1000 +ARG APP_GID=1000 + # Create non-root user -RUN groupadd --system appgroup && useradd --system --gid appgroup appuser +RUN set -eu; \ + group_name=appgroup; \ + existing_group="$(getent group "${APP_GID}" | cut -d: -f1 || true)"; \ + if [ -n "$existing_group" ]; then \ + group_name="$existing_group"; \ + else \ + groupadd --system -g "${APP_GID}" appgroup; \ + fi; \ + if id -u appuser >/dev/null 2>&1; then \ + usermod -u "${APP_UID}" -g "$group_name" appuser; \ + else \ + useradd --system -u "${APP_UID}" -g "$group_name" -o appuser; \ + fi # Install system dependencies RUN apt-get update && \ @@ -16,9 +31,9 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ WORKDIR /app # Set permissions -RUN chown -R appuser:appgroup /app && \ +RUN chown -R appuser:$(id -gn appuser) /app && \ mkdir -p /home/appuser/.local && \ - chown -R appuser:appgroup /home/appuser + chown -R appuser:$(id -gn appuser) /home/appuser # Switch to non-root user USER appuser @@ -27,4 +42,4 @@ USER appuser ENV PIP_USER=1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1 ENV PATH=/home/appuser/.local/bin:$PATH -ENV HOME=/home/appuser \ No newline at end of file +ENV HOME=/home/appuser diff --git a/docker/runner/Dockerfile.python-3.12 b/docker/runner/Dockerfile.python-3.12 index 59dd6dc..c0b3706 100644 --- a/docker/runner/Dockerfile.python-3.12 +++ b/docker/runner/Dockerfile.python-3.12 @@ -1,7 +1,22 @@ FROM python:3.12-slim +ARG APP_UID=1000 +ARG APP_GID=1000 + # Create non-root user -RUN addgroup --system appgroup && adduser --system --group appuser +RUN set -eu; \ + group_name=appgroup; \ + existing_group="$(getent group "${APP_GID}" | cut -d: -f1 || true)"; \ + if [ -n "$existing_group" ]; then \ + group_name="$existing_group"; \ + else \ + groupadd --system -g "${APP_GID}" appgroup; \ + fi; \ + if id -u appuser >/dev/null 2>&1; then \ + usermod -u "${APP_UID}" -g "$group_name" appuser; \ + else \ + useradd --system -u "${APP_UID}" -g "$group_name" -o appuser; \ + fi # Install system dependencies RUN apt-get update && \ @@ -17,9 +32,9 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ WORKDIR /app # Set permissions -RUN chown -R appuser:appgroup /app && \ +RUN chown -R appuser:$(id -gn appuser) /app && \ mkdir -p /home/appuser/.local && \ - chown -R appuser:appgroup /home/appuser + chown -R appuser:$(id -gn appuser) /home/appuser # Switch to non-root user USER appuser @@ -28,4 +43,4 @@ USER appuser ENV PIP_USER=1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1 ENV PATH=/home/appuser/.local/bin:$PATH -ENV HOME=/home/appuser \ No newline at end of file +ENV HOME=/home/appuser diff --git a/scripts/build-runners.sh b/scripts/build-runners.sh index 3c30e81..4d09b60 100755 --- a/scripts/build-runners.sh +++ b/scripts/build-runners.sh @@ -35,6 +35,8 @@ done docker info >/dev/null 2>&1 || { err "Docker not accessible. Run with sudo or add your user to the docker group."; exit 1; } +set_service_ids + printf "Building runner images\n" if ((${#args[@]} > 0)); then build_runner_images "${args[@]}" diff --git a/scripts/clean.sh b/scripts/clean.sh index bea294f..bc4de4d 100755 --- a/scripts/clean.sh +++ b/scripts/clean.sh @@ -86,7 +86,7 @@ if ((keep_docker==0)); then fi # Volumes - volumes=$(docker volume ls --filter "name=devpush" -q 2>/dev/null || true) + volumes=$(docker volume ls --filter "label=com.docker.compose.project=devpush" -q 2>/dev/null || true) if [[ -n "$volumes" ]]; then count=$(printf '%s\n' "$volumes" | wc -l | tr -d ' ') run_cmd --try "${CHILD_MARK} Removing volumes ($count found)" docker volume rm $volumes diff --git a/scripts/db-generate.sh b/scripts/db-generate.sh index 7c31301..18cc742 100755 --- a/scripts/db-generate.sh +++ b/scripts/db-generate.sh @@ -52,7 +52,6 @@ read -r -p "Migration message: " message # Generate migration printf '\n' -printf "Generating migration\n" run_cmd "Generating migration" "${COMPOSE_BASE[@]}" exec -T app uv run alembic revision --autogenerate -m "$message" # Success message diff --git a/scripts/db-migrate.sh b/scripts/db-migrate.sh index c867bc6..ef04397 100755 --- a/scripts/db-migrate.sh +++ b/scripts/db-migrate.sh @@ -99,4 +99,5 @@ printf '\n' run_cmd "Apply migrations" "${COMPOSE_BASE[@]}" exec -T app uv run alembic upgrade head # Success message +printf '\n' printf "${GRN}Migrations applied. ✔${NC}\n" diff --git a/scripts/install.sh b/scripts/install.sh index e37b824..99b1cd7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -251,7 +251,7 @@ run_cmd "${CHILD_MARK} Installing Docker packages" apt_install docker-ce docker- # Ensure Docker service is running run_cmd "${CHILD_MARK} Enabling Docker service" systemctl enable --now docker -run_cmd "${CHILD_MARK} Waiting for Docker daemon" bash -lc 'for i in $(seq 1 15); do docker info >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1' +run_cmd "${CHILD_MARK} Waiting for Docker daemon" bash -lc 'for i in $(seq 1 30); do docker info >/dev/null 2>&1 && exit 0; sleep 2; done; exit 1' # Create user if ! id -u "$service_user" >/dev/null 2>&1; then diff --git a/scripts/lib.sh b/scripts/lib.sh index 5fa7866..b3e6fda 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -319,7 +319,8 @@ init_script_logging() { CURRENT_SCRIPT_NAME="$name" install -d -m 0750 "$log_dir" >/dev/null 2>&1 || true - SCRIPT_ERR_LOG="$log_dir/${name}_error.log" + SCRIPT_ERR_LOG="$log_dir/${name}-error.log" + ln -sfn "$SCRIPT_ERR_LOG" "$log_dir/${name}_error.log" >/dev/null 2>&1 || true exec 2> >(tee "$SCRIPT_ERR_LOG" >&2) trap '_script_err_trap' ERR } @@ -393,6 +394,12 @@ build_runner_images() { built=1 local build_cmd=(docker build -f "$dockerfile" -t "runner-$slug") ((no_cache==1)) && build_cmd+=(--no-cache) + if [[ -n "${SERVICE_UID:-}" ]]; then + build_cmd+=(--build-arg "APP_UID=$SERVICE_UID") + fi + if [[ -n "${SERVICE_GID:-}" ]]; then + build_cmd+=(--build-arg "APP_GID=$SERVICE_GID") + fi build_cmd+=("$dockerfile_dir") if ! run_cmd --try "${CHILD_MARK} ${label}" "${build_cmd[@]}"; then ((failed+=1)) diff --git a/scripts/runner-exec.sh b/scripts/runner-exec.sh new file mode 100755 index 0000000..7ed57ea --- /dev/null +++ b/scripts/runner-exec.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +usage(){ + cat < [command...] + +Execute a command inside the runner container for a deployment. + +Examples: + scripts/runner-exec.sh 685802df5caf6c5ebc2c12074ecb78ed ls -la /data + scripts/runner-exec.sh 685802df5caf6c5ebc2c12074ecb78ed bash -lc 'ls -la /data' + scripts/runner-exec.sh 685802df5caf6c5ebc2c12074ecb78ed +USG + exit 0 +} + +if [[ "$1" == "-h" || "$1" == "--help" ]]; then + usage +fi + +if [[ $# -lt 1 ]]; then + usage +fi + +deployment_id="$1" +shift + +short_id="${deployment_id:0:7}" +container_name="runner-${short_id}" + +container_id="$(docker ps --filter "name=^/${container_name}$" --format "{{.ID}}")" +if [[ -z "$container_id" ]]; then + printf "Error: runner container not found for deployment %s (expected: %s)\n" "$deployment_id" "$container_name" >&2 + exit 1 +fi + +printf "Executing in %s (%s)\n" "$container_name" "$container_id" +if [[ $# -eq 0 ]]; then + docker exec -it "$container_id" /bin/sh +else + docker exec -i "$container_id" "$@" +fi