Add SPA UI for GARM

This change adds a single page application front-end to GARM. It uses
a generated REST client, built from the swagger definitions, the websocket
interface for live updates of entities and eager loading of everything
except runners, as users may have many runners and we don't want to load
hundreds of runners in memory.

Proper pagination should be implemented in the API, in future commits,
to avoid loading lots of elements for no reason.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
This commit is contained in:
Gabriel Adrian Samfira 2025-08-12 09:28:21 +00:00
parent a811d129d0
commit eec158b32c
230 changed files with 47324 additions and 2045 deletions

2
webapp/.env.development Normal file
View file

@ -0,0 +1,2 @@
VITE_GARM_API_URL=http://localhost:9997
NODE_ENV=development

8
webapp/.env.example Normal file
View file

@ -0,0 +1,8 @@
# Development Environment Variables
# GARM Backend API URL (for development only)
# When set, the frontend will connect to this URL instead of using proxy
# VITE_GARM_API_URL=http://localhost:9997
# Node Environment (automatically set by npm scripts)
# NODE_ENV=development

79
webapp/DEV_SETUP.md Normal file
View file

@ -0,0 +1,79 @@
# Development Setup
The web app can be started with the `npm run dev` command, which will start a development server with hot reloading. To properly work, there are a number of prerequisites you need to have and some GARM settings to tweak.
## Prerequisites
To have a full development setup, you will need the following prerequisites:
- **Node.js 24+** and **npm**
- **Go 1.24+** (for building the GARM backend)
- **openapi-generator-cli** in your PATH (for API client generation)
The `openapi-generator-cli` will also need java to be installed. If you're running on Ubuntu, running:
```bash
sudo apt-get install default-jre
```
should be enough. Different distros should have an equivalent package available.
>[!NOTE]
>If you don't need to change the web app, you don't need to rebuild it. There is already a pre-built version in the repo.
## Necessary GARM settings
GARM has strict origin checks for websockets and API calls. To allow your local development server to communicate with the GARM backend, you need to configure the following settings:
```toml
[apiserver]
cors_origins = ["https://garm.example.com", "http://127.0.0.1:5173"]
```
>[!IMPORTANT]
> You must include the port.
>[!IMPORTANT]
> Omitting the `cors_origins` option will automatically check same host origin.
## Development Server
Your GARM server can be started and hosted anywhere. As long as you set the proper `cors_origins` URLs, your web-ui development server can be separate from your GARM server. To point the web app to the GARM server, you will need to create an `.env.development` file in the `webapp/` directory:
```bash
cd /home/ubuntu/garm/webapp
echo "VITE_GARM_API_URL=http://localhost:9997" > .env
echo "NODE_ENV=development" >> .env
npm run dev
```
## Asset Management
During development:
- SVG icons are served from `static/assets/`
- Favicons are served from `static/`
- All static assets are copied from `assets/assets/` to `static/assets/`
## Building for Production
For production deployments, the web app is embedded into the GARM binary. You don't need to serve it separately. To build the web app and embed it into the binary, run the following 2 commands:
```bash
# Build the static webapp
make build-webui
# Build the garm binary with the webapp embedded
make build
```
This creates the production build with:
- Base path set to `/ui`
- All assets embedded for Go to serve
- Optimized bundles
>[!IMPORTANT]
>The web UI is an optional feature in GARM. For the `/ui` URL to be available, you will need to enable it in the garm config file under:
>```toml
>[apiserver.webui]
> enable=true
>```
>See the sample config file in the `testdata/config.toml` file.

102
webapp/README.md Normal file
View file

@ -0,0 +1,102 @@
# GARM SPA (SvelteKit)
This is a Single Page Application (SPA) implementation of the GARM web interface using SvelteKit.
## Features
- **Lightweight**: Uses SvelteKit for minimal bundle size and fast performance
- **Modern**: TypeScript-first development with full type safety
- **Responsive**: Mobile-first design using Tailwind CSS
- **Real-time**: WebSocket integration for live updates
- **API-driven**: Uses the existing GARM REST API endpoints
### Quick Start
1. **Clone the repository** (if not already done)
```bash
git clone https://github.com/cloudbase/garm.git
cd garm
```
2. **Build and test GARM with embedded webapp**
```bash
# You can skip this command if you made no changes to the webapp.
make build-webui
# builds the binary, with the web UI embedded.
make build
```
Make sure you enable the webui in the config:
```toml
[apiserver.webui]
enable=true
```
3. **Access the webapp**
- Navigate to `http://localhost:9997/ui/` (or your configured fqdn and port)
### Development Workflow
See the [DEV_SETUP.md](DEV_SETUP.md) file.
### Git Workflow
**DO NOT commit** the following directories:
- `webapp/node_modules/` - Dependencies (managed by package-lock.json)
- `webapp/.svelte-kit/` - Build cache and generated files
- `webapp/build/` - Production build output
These are already included in `.gitignore`. Only commit source files in `webapp/src/` and configuration files.
### API Client Generation
The webapp uses auto-generated TypeScript clients from the GARM OpenAPI spec using `go generate`. To regenerate the clients, mocks and everything else, run:
```bash
go generate ./...
```
In the root folder of the project.
>[!NOTE]
> See [DEV_SETUP.md](DEV_SETUP.md) for prerequisites, before you try to generate the files.
### Asset Serving
The webapp is embedded using Go's `embed` package in `webapp/assets/assets.go`:
```go
//go:embed all:*
var EmbeddedSPA embed.FS
```
This allows GARM to serve the entire webapp with zero external dependencies. The webapp assets are compiled into the Go binary at build time.
## Running GARM behind a reverse proxy
In production, GARM will serve the web UI and assets from the embedded files inside the binary. The web UI also relies on the [events](/doc/events.md) API for real-time updates.
To have a fully working experience, you will need to configure your reverse proxy to allow websocket upgrades. For an `nginx` example, see [the sample config in the testdata folder](/testdata/nginx-server.conf).
Additionally, in production you can also override the default web UI that is embedded in GARM, without updating the garm binary. To do that, build the webapp, place it in the document root of `nginx` and create a new `location /ui` config in nginx. Something like the following should work:
```
# Place this before the proxy_pass location
location ~ ^/ui(/.*)?$ {
root /var/www/html/garm-webui/;
}
location / {
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $http_host;
proxy_pass http://garm_backend;
proxy_set_header Host $Host;
proxy_redirect off;
}
```
This should allow you to override the default web UI embedded in GARM without updating the GARM binary.

View file

@ -0,0 +1 @@
export const env={}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{M as K,K as T,L as j,N as C,_ as F,a0 as q,a1 as $,Y as z,a2 as x,O as G,P as A,Q as H,at as J,R as Z,aa as Q,U as V,T as W,au as D,m as X,av as k,s as U,J as ee,g as m,aw as re,ax as ne,ay as w,az as se,aA as M,ar as ae,q as ie,aB as te,aj as R,aC as ue,a6 as fe,aD as le,u as oe,aE as ce,aF as de,aG as _e,aH as N,aI as L,aJ as pe,aK as ve,S as Y,aL as B,aM as S}from"./D8EpLgQ1.js";function Ie(e,r,s=!1){T&&j();var n=e,a=null,i=null,l=J,d=s?C:0,p=!1;const P=(o,u=!0)=>{p=!0,_(u,o)};var f=null;function I(){f!==null&&(f.lastChild.remove(),n.before(f),f=null);var o=l?a:i,u=l?i:a;o&&Q(o),u&&V(u,()=>{l?i=null:a=null})}const _=(o,u)=>{if(l===(l=o))return;let g=!1;if(T){const E=F(n)===q;!!l===E&&(n=$(),z(n),x(!1),g=!0)}var b=Z(),c=n;if(b&&(f=document.createDocumentFragment(),f.append(c=G())),l?a??=u&&A(()=>u(c)):i??=u&&A(()=>u(c)),b){var h=H,t=l?a:i,v=l?i:a;t&&h.skipped_effects.delete(t),v&&h.skipped_effects.add(v),h.add_callback(I)}else I();g&&x(!0)};K(()=>{p=!1,r(P),p||_(null,null)},d),T&&(n=W)}let O=!1,y=Symbol();function ge(e,r,s){const n=s[r]??={store:null,source:X(void 0),unsubscribe:D};if(n.store!==e&&!(y in s))if(n.unsubscribe(),n.store=e??null,e==null)n.source.v=void 0,n.unsubscribe=D;else{var a=!0;n.unsubscribe=k(e,i=>{a?n.source.v=i:U(n.source,i)}),a=!1}return e&&y in s?ee(e):m(n.source)}function Ee(){const e={};function r(){re(()=>{for(var s in e)e[s].unsubscribe();ne(e,y,{enumerable:!1,value:!0})})}return[e,r]}function be(e){var r=O;try{return O=!1,[e(),O]}finally{O=r}}const he={get(e,r){if(!e.exclude.includes(r))return m(e.version),r in e.special?e.special[r]():e.props[r]},set(e,r,s){if(!(r in e.special)){var n=R;try{L(e.parent_effect),e.special[r]=me({get[r](){return e.props[r]}},r,M)}finally{L(n)}}return e.special[r](s),N(e.version),!0},getOwnPropertyDescriptor(e,r){if(!e.exclude.includes(r)&&r in e.props)return{enumerable:!0,configurable:!0,value:e.props[r]}},deleteProperty(e,r){return e.exclude.includes(r)||(e.exclude.push(r),N(e.version)),!0},has(e,r){return e.exclude.includes(r)?!1:r in e.props},ownKeys(e){return Reflect.ownKeys(e.props).filter(r=>!e.exclude.includes(r))}};function Oe(e,r){return new Proxy({props:e,exclude:r,special:{},version:fe(0),parent_effect:R},he)}const Se={get(e,r){let s=e.props.length;for(;s--;){let n=e.props[s];if(S(n)&&(n=n()),typeof n=="object"&&n!==null&&r in n)return n[r]}},set(e,r,s){let n=e.props.length;for(;n--;){let a=e.props[n];S(a)&&(a=a());const i=w(a,r);if(i&&i.set)return i.set(s),!0}return!1},getOwnPropertyDescriptor(e,r){let s=e.props.length;for(;s--;){let n=e.props[s];if(S(n)&&(n=n()),typeof n=="object"&&n!==null&&r in n){const a=w(n,r);return a&&!a.configurable&&(a.configurable=!0),a}}},has(e,r){if(r===Y||r===B)return!1;for(let s of e.props)if(S(s)&&(s=s()),s!=null&&r in s)return!0;return!1},ownKeys(e){const r=[];for(let s of e.props)if(S(s)&&(s=s()),!!s){for(const n in s)r.includes(n)||r.push(n);for(const n of Object.getOwnPropertySymbols(s))r.includes(n)||r.push(n)}return r}};function Te(...e){return new Proxy({props:e},Se)}function me(e,r,s,n){var a=!ce||(s&de)!==0,i=(s&le)!==0,l=(s&pe)!==0,d=n,p=!0,P=()=>(p&&(p=!1,d=l?oe(n):n),d),f;if(i){var I=Y in e||B in e;f=w(e,r)?.set??(I&&r in e?t=>e[r]=t:void 0)}var _,o=!1;i?[_,o]=be(()=>e[r]):_=e[r],_===void 0&&n!==void 0&&(_=P(),f&&(a&&se(),f(_)));var u;if(a?u=()=>{var t=e[r];return t===void 0?P():(p=!0,t)}:u=()=>{var t=e[r];return t!==void 0&&(d=void 0),t===void 0?d:t},a&&(s&M)===0)return u;if(f){var g=e.$$legacy;return function(t,v){return arguments.length>0?((!a||!v||g||o)&&f(v?u():t),t):u()}}var b=!1,c=((s&_e)!==0?ae:ie)(()=>(b=!1,u()));i&&m(c);var h=R;return function(t,v){if(arguments.length>0){const E=v?m(c):a&&i?te(t):t;return U(c,E),b=!0,d!==void 0&&(d=E),t}return ve&&b||(h.f&ue)!==0?c.v:m(c)}}export{ge as a,Te as b,Ie as i,Oe as l,me as p,Ee as s};

View file

@ -0,0 +1 @@
import{am as g,an as d,ao as c,u as m,ap as b,aq as i,g as p,n as v,ar as h,as as k}from"./D8EpLgQ1.js";function x(n=!1){const s=g,e=s.l.u;if(!e)return;let f=()=>v(s.s);if(n){let t=0,a={};const _=h(()=>{let l=!1;const r=s.s;for(const o in r)r[o]!==a[o]&&(a[o]=r[o],l=!0);return l&&t++,t});f=()=>p(_)}e.b.length&&d(()=>{u(s,f),i(e.b)}),c(()=>{const t=m(()=>e.m.map(b));return()=>{for(const a of t)typeof a=="function"&&a()}}),e.a.length&&c(()=>{u(s,f),i(e.a)})}function u(n,s){if(n.l.s)for(const e of n.l.s)p(e);s()}k();export{x as i};

View file

@ -0,0 +1 @@
import"./DsnmJJEf.js";import{i as j}from"./B3Pzt0F_.js";import{p as R,l as w,a as q,f as g,t as v,c as k,d as A,k as B,j as u,s as _,m as y,r as m,n as f,u as b,g as d,v as h}from"./D8EpLgQ1.js";import{p as o,i as D}from"./5WA7h8uK.js";import{c as U,s as F}from"./CiE1LlKV.js";import{b as r}from"./CoIRRsD9.js";var G=g('<div class="text-sm text-gray-500 dark:text-gray-400 truncate"> </div>'),H=g('<div class="w-full min-w-0 text-sm font-medium"><a> </a> <!></div>');function V(x,n){R(n,!1);const i=y(),p=y();let e=o(n,"item",8),s=o(n,"entityType",8,"repository"),$=o(n,"showOwner",8,!1),E=o(n,"showId",8,!1),I=o(n,"fontMono",8,!1);function z(){if(!e())return"Unknown";switch(s()){case"repository":return $()?`${e().owner||"Unknown"}/${e().name||"Unknown"}`:e().name||"Unknown";case"organization":case"enterprise":return e().name||"Unknown";case"pool":return E()?e().id||"Unknown":e().name||"Unknown";case"scaleset":return e().name||"Unknown";case"instance":return e().name||"Unknown";default:return e().name||e().id||"Unknown"}}function C(){if(!e())return"#";let t;switch(s()){case"instance":t=e().name;break;default:t=e().id||e().name;break}if(!t)return"#";switch(s()){case"repository":return`${r}/repositories/${t}`;case"organization":return`${r}/organizations/${t}`;case"enterprise":return`${r}/enterprises/${t}`;case"pool":return`${r}/pools/${t}`;case"scaleset":return`${r}/scalesets/${t}`;case"instance":return`${r}/instances/${encodeURIComponent(t)}`;default:return"#"}}w(()=>{},()=>{_(i,z())}),w(()=>{},()=>{_(p,C())}),q(),j();var c=H(),a=u(c),M=u(a,!0);m(a);var N=B(a,2);{var O=t=>{var l=G(),T=u(l,!0);m(l),v(()=>h(T,(f(e()),b(()=>e().provider_id)))),k(t,l)};D(N,t=>{f(s()),f(e()),b(()=>s()==="instance"&&e()?.provider_id)&&t(O)})}m(c),v(()=>{U(a,"href",d(p)),F(a,1,`block w-full truncate text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 ${I()?"font-mono":""}`),U(a,"title",d(i)),h(M,d(i))}),k(x,c),A()}export{V as E};

View file

@ -0,0 +1 @@
import{F as t,G as S,u as b,H as h,S as k}from"./D8EpLgQ1.js";function u(r,i){return r===i||r?.[k]===i}function d(r={},i,a,T){return t(()=>{var f,s;return S(()=>{f=s,s=[],b(()=>{r!==a(...s)&&(i(r,...s),f&&u(a(...f),r)&&i(null,...f))})}),()=>{h(()=>{s&&u(a(...s),r)&&i(null,...s)})}}),r}export{d as b};

View file

@ -0,0 +1 @@
import"./DsnmJJEf.js";import{i as v}from"./B3Pzt0F_.js";import{p as w,l as m,n as s,g as r,m as g,a as x,B as h,b as T,c as B,d as S,s as k,u}from"./D8EpLgQ1.js";import{k as A}from"./C9DJVOi1.js";import{p as d}from"./5WA7h8uK.js";import{k as b,B as C}from"./BGVHQGl-.js";import{f as E}from"./ow_oMtSd.js";function q(_,i){w(i,!1);const c=g(),n=g();let e=d(i,"item",8),l=d(i,"statusType",8,"entity"),a=d(i,"statusField",8,"status");m(()=>(s(e()),s(a())),()=>{k(c,e()?.[a()]||"unknown")}),m(()=>(s(e()),s(l()),r(c),s(a())),()=>{k(n,(()=>{if(!e())return{variant:"error",text:"Unknown"};switch(l()){case"entity":return b(e());case"instance":let t="secondary";switch(r(c).toLowerCase()){case"running":t="success";break;case"stopped":t="info";break;case"creating":case"pending_create":t="warning";break;case"deleting":case"pending_delete":case"pending_force_delete":t="warning";break;case"error":case"deleted":t="error";break;case"active":case"online":t="success";break;case"idle":t="info";break;case"pending":case"installing":t="warning";break;case"failed":case"terminated":case"offline":t="error";break;case"unknown":default:t="secondary";break}return{variant:t,text:E(r(c))};case"enabled":return{variant:e().enabled?"success":"error",text:e().enabled?"Enabled":"Disabled"};case"custom":const o=e()[a()]||"Unknown";if(a()==="auth-type"){const f=o==="pat"||!o?"pat":"app";return{variant:f==="pat"?"success":"info",text:f==="pat"?"PAT":"App"}}return{variant:"info",text:o};default:return b(e())}})())}),x(),v();var p=h(),y=T(p);A(y,()=>(s(e()),s(a()),u(()=>`${e()?.name||"item"}-${e()?.[a()]||"status"}-${e()?.updated_at||"time"}`)),t=>{C(t,{get variant(){return r(n),u(()=>r(n).variant)},get text(){return r(n),u(()=>r(n).text)}})}),B(_,p),S()}export{q as S};

View file

@ -0,0 +1 @@
import{I as u}from"./D8EpLgQ1.js";function c(){const{subscribe:s,set:i,update:o}=u([]),n={subscribe:s,add:e=>{const t=Math.random().toString(36).substr(2,9),r={...e,id:t,duration:e.duration??5e3};return o(a=>[...a,r]),r.duration&&r.duration>0&&setTimeout(()=>{o(a=>a.filter(d=>d.id!==t))},r.duration),t},remove:e=>{o(t=>t.filter(r=>r.id!==e))},clear:()=>{i([])},success:(e,t="",r)=>n.add({type:"success",title:e,message:t,duration:r}),error:(e,t="",r)=>n.add({type:"error",title:e,message:t,duration:r}),info:(e,t="",r)=>n.add({type:"info",title:e,message:t,duration:r}),warning:(e,t="",r)=>n.add({type:"warning",title:e,message:t,duration:r})};return n}const p=c();export{p as t};

View file

@ -0,0 +1,4 @@
import"./DsnmJJEf.js";import{i as m}from"./B3Pzt0F_.js";import{p as w,l as x,n as d,a as k,f as _,t as b,c as v,d as y,s as h,m as E,j as B,r as z,g as L,v as M}from"./D8EpLgQ1.js";import{s as j,e as $}from"./CiE1LlKV.js";import{p as o}from"./5WA7h8uK.js";function S(e){if(!e)return"N/A";try{return(typeof e=="string"?new Date(e):e).toLocaleString()}catch{return"Invalid Date"}}function A(e,r="w-4 h-4"){return e==="gitea"?`<svg class="${r}" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" style="fill:#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" style="fill:#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" style="fill:#609926"/></svg>`:e==="github"?`<div class="inline-flex ${r}"><svg class="${r} dark:hidden" width="98" height="96" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 96"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg><svg class="${r} hidden dark:block" width="98" height="96" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 96"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg></div>`:`<svg class="${r} text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>`}function C(e,r){if(e.repo_name)return e.repo_name;if(e.org_name)return e.org_name;if(e.enterprise_name)return e.enterprise_name;if(e.repo_id&&!e.repo_name&&r?.repositories){const n=r.repositories.find(t=>t.id===e.repo_id);return n?`${n.owner}/${n.name}`:"Unknown Entity"}if(e.org_id&&!e.org_name&&r?.organizations){const n=r.organizations.find(t=>t.id===e.org_id);return n&&n.name?n.name:"Unknown Entity"}if(e.enterprise_id&&!e.enterprise_name&&r?.enterprises){const n=r.enterprises.find(t=>t.id===e.enterprise_id);return n&&n.name?n.name:"Unknown Entity"}return"Unknown Entity"}function H(e){return e.repo_id?"repository":e.org_id?"organization":e.enterprise_id?"enterprise":"unknown"}function P(e,r=""){return e.repo_id?`${r}/repositories/${e.repo_id}`:e.org_id?`${r}/organizations/${e.org_id}`:e.enterprise_id?`${r}/enterprises/${e.enterprise_id}`:"#"}function V(e){e&&(e.scrollTop=e.scrollHeight)}function W(e){return{newPerPage:e,newCurrentPage:1}}function q(e){return e.pool_manager_status?.running?{text:"Running",variant:"success"}:{text:"Stopped",variant:"error"}}function G(e){switch(e.toLowerCase()){case"error":return{text:"Error",variant:"error"};case"warning":return{text:"Warning",variant:"warning"};case"info":return{text:"Info",variant:"info"};default:return{text:e,variant:"info"}}}function l(e,r,n){if(!r.trim())return e;const t=r.toLowerCase();return e.filter(a=>typeof n=="function"?n(a).toLowerCase().includes(t):n.some(i=>a[i]?.toString().toLowerCase().includes(t)))}function J(e,r){return l(e,r,["name","owner"])}function K(e,r){return l(e,r,["name"])}function O(e,r){return l(e,r,n=>[n.name||"",n.description||"",n.endpoint?.name||""].join(" "))}function Q(e,r){return l(e,r,["name","description","base_url","api_base_url"])}function X(e,r,n){return e.slice((r-1)*n,r*n)}var T=_("<span> </span>");function Y(e,r){w(r,!1);const n=E();let t=o(r,"variant",8,"gray"),a=o(r,"size",8,"sm"),i=o(r,"text",8),g=o(r,"ring",8,!1);const c={success:"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200",error:"bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200",warning:"bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200",info:"bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200",gray:"bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200",blue:"bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200",green:"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200",red:"bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200",yellow:"bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200",secondary:"bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200"},u={success:"ring-green-600/20 dark:ring-green-400/30",error:"ring-red-600/20 dark:ring-red-400/30",warning:"ring-yellow-600/20 dark:ring-yellow-400/30",info:"ring-blue-600/20 dark:ring-blue-400/30",gray:"ring-gray-500/20 dark:ring-gray-400/30",blue:"ring-blue-600/20 dark:ring-blue-400/30",green:"ring-green-600/20 dark:ring-green-400/30",red:"ring-red-600/20 dark:ring-red-400/30",yellow:"ring-yellow-600/20 dark:ring-yellow-400/30",secondary:"ring-gray-500/20 dark:ring-gray-400/30"},f={sm:"px-2 py-1 text-xs",md:"px-2.5 py-0.5 text-xs"};x(()=>(d(t()),d(a()),d(g())),()=>{h(n,["inline-flex items-center rounded-full font-semibold",c[t()],f[a()],g()?`ring-1 ring-inset ${u[t()]}`:""].filter(Boolean).join(" "))}),k(),m();var s=T(),p=B(s,!0);z(s),b(()=>{j(s,1,$(L(n))),M(p,i())}),v(e,s),y()}export{Y as B,Q as a,S as b,W as c,G as d,C as e,O as f,A as g,l as h,H as i,P as j,q as k,K as l,J as m,X as p,V as s};

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{s as e}from"./CTf6mQoE.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};

View file

@ -0,0 +1 @@
import{V as b,W as o,u as h,G as _,K as t,Q as f,X as m}from"./D8EpLgQ1.js";function y(e,a,c=a){var v=b(),d=new WeakSet;o(e,"input",r=>{var l=r?e.defaultValue:e.value;if(l=n(e)?s(l):l,c(l),f!==null&&d.add(f),v&&l!==(l=a())){var k=e.selectionStart,u=e.selectionEnd;e.value=l??"",u!==null&&(e.selectionStart=k,e.selectionEnd=Math.min(u,e.value.length))}}),(t&&e.defaultValue!==e.value||h(a)==null&&e.value)&&(c(n(e)?s(e.value):e.value),f!==null&&d.add(f)),_(()=>{var r=a();if(e===document.activeElement){var l=m??f;if(d.has(l))return}n(e)&&r===s(e.value)||e.type==="date"&&!r&&!e.value||r!==e.value&&(e.value=r??"")})}function E(e,a,c=a){o(e,"change",v=>{var d=v?e.defaultChecked:e.checked;c(d)}),(t&&e.defaultChecked!==e.checked||h(a)==null)&&c(e.checked),_(()=>{var v=a();e.checked=!!v})}function n(e){var a=e.type;return a==="number"||a==="range"}function s(e){return e===""?null:+e}export{E as a,y as b};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{K as l,L as u,M as m,N as _,O as p,P as h,Q as v,R as b,T,U as g}from"./D8EpLgQ1.js";function y(s,i,d){l&&u();var r=s,a,n,e=null,t=null;function f(){n&&(g(n),n=null),e&&(e.lastChild.remove(),r.before(e),e=null),n=t,t=null}m(()=>{if(a!==(a=i())){var c=b();if(a){var o=r;c&&(e=document.createDocumentFragment(),e.append(o=p())),t=h(()=>d(o,a))}c?v.add_callback(f):f()}},_),l&&(r=T)}export{y as c};

View file

@ -0,0 +1 @@
import"./DsnmJJEf.js";import{i as _}from"./B3Pzt0F_.js";import{p as h,f as x,t as u,c as g,d as k,k as w,j as o,u as m,n as e,r,v as y}from"./D8EpLgQ1.js";import{h as b}from"./CiE1LlKV.js";import{p}from"./5WA7h8uK.js";import{g as v}from"./BGVHQGl-.js";var z=x('<div class="flex items-center"><div class="flex-shrink-0 mr-2"><!></div> <div class="text-sm text-gray-900 dark:text-white"> </div></div>');function U(l,i){h(i,!1);let t=p(i,"item",8),s=p(i,"iconSize",8,"w-5 h-5");_();var a=z(),n=o(a),f=o(n);b(f,()=>(e(v),e(t()),e(s()),m(()=>v(t()?.endpoint?.endpoint_type||t()?.endpoint_type||"unknown",s())))),r(n);var d=w(n,2),c=o(d,!0);r(d),r(a),u(()=>y(c,(e(t()),m(()=>t()?.endpoint?.name||t()?.endpoint_name||t()?.github_endpoint_name||"Unknown")))),g(l,a),k()}export{U as E};

View file

@ -0,0 +1 @@
import"./DsnmJJEf.js";import{i as b}from"./B3Pzt0F_.js";import{p as k,f as E,t as C,u as i,n as t,v as n,c as j,d as P,k as z,j as l,r as o}from"./D8EpLgQ1.js";import{c as N}from"./CiE1LlKV.js";import{p as f}from"./5WA7h8uK.js";import"./CoIRRsD9.js";import{j as x,e as c,i as u}from"./BGVHQGl-.js";var T=E('<div class="flex flex-col"><a class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"> </a> <span class="text-xs text-gray-500 dark:text-gray-400 capitalize"> </span></div>');function G(d,r){k(r,!1);let e=f(r,"item",8),m=f(r,"eagerCache",8,null);b();var s=T(),a=l(s),v=l(a,!0);o(a);var p=z(a,2),g=l(p,!0);o(p),o(s),C((h,y,_)=>{N(a,"href",h),n(v,y),n(g,_)},[()=>(t(x),t(e()),i(()=>x(e()))),()=>(t(c),t(e()),t(m()),i(()=>c(e(),m()))),()=>(t(u),t(e()),i(()=>u(e())))]),j(d,s),P()}export{G as P};

View file

@ -0,0 +1 @@
import"./DsnmJJEf.js";import{i as j}from"./B3Pzt0F_.js";import{p as E,E as G,f as S,j as t,r,k as g,u,n as p,z as m,t as z,v as D,e as f,c as H,d as I}from"./D8EpLgQ1.js";import{h as y,s as v}from"./CiE1LlKV.js";import{p as h}from"./5WA7h8uK.js";import{g as o}from"./BGVHQGl-.js";var q=S('<fieldset><legend class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> </legend> <div class="grid grid-cols-2 gap-4"><button type="button"><!> <span class="mt-2 text-sm font-medium text-gray-900 dark:text-white">GitHub</span></button> <button type="button"><!> <span class="mt-2 text-sm font-medium text-gray-900 dark:text-white">Gitea</span></button></div></fieldset>');function M(x,s){E(s,!1);const k=G();let d=h(s,"selectedForgeType",12,""),_=h(s,"label",8,"Select Forge Type");function n(c){d(c),k("select",c)}j();var i=q(),l=t(i),F=t(l,!0);r(l);var b=g(l,2),e=t(b),w=t(e);y(w,()=>(p(o),u(()=>o("github","w-8 h-8")))),m(2),r(e);var a=g(e,2),T=t(a);y(T,()=>(p(o),u(()=>o("gitea","w-8 h-8")))),m(2),r(a),r(b),r(i),z(()=>{D(F,_()),v(e,1,`flex flex-col items-center justify-center p-6 border-2 rounded-lg transition-colors cursor-pointer ${d()==="github"?"border-blue-500 bg-blue-50 dark:bg-blue-900":"border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500"}`),v(a,1,`flex flex-col items-center justify-center p-6 border-2 rounded-lg transition-colors cursor-pointer ${d()==="gitea"?"border-blue-500 bg-blue-50 dark:bg-blue-900":"border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500"}`)}),f("click",e,()=>n("github")),f("click",a,()=>n("gitea")),H(x,i),I()}export{M as F};

View file

@ -0,0 +1 @@
import"./DsnmJJEf.js";import{i as E}from"./B3Pzt0F_.js";import{p as H,E as L,f as h,t as f,c,d as z,j as e,r as a,k as x,v as d,z as M,D as q}from"./D8EpLgQ1.js";import{p as i,i as C}from"./5WA7h8uK.js";import{B as F}from"./CiE1LlKV.js";var G=h('<div class="mt-4 sm:mt-0 flex items-center space-x-4"><!></div>'),I=h('<div class="sm:flex sm:items-center sm:justify-between"><div><h1 class="text-2xl font-bold text-gray-900 dark:text-white"> </h1> <p class="mt-2 text-sm text-gray-700 dark:text-gray-300"> </p></div> <!></div>');function S(u,t){H(t,!1);const _=L();let k=i(t,"title",8),b=i(t,"description",8),v=i(t,"actionLabel",8,null),g=i(t,"showAction",8,!0);function w(){_("action")}E();var r=I(),s=e(r),o=e(s),y=e(o,!0);a(o);var m=x(o,2),j=e(m,!0);a(m),a(s);var A=x(s,2);{var P=n=>{var l=G(),B=e(l);F(B,{variant:"primary",icon:'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />',$$events:{click:w},children:(D,J)=>{M();var p=q();f(()=>d(p,v())),c(D,p)},$$slots:{default:!0}}),a(l),c(n,l)};C(A,n=>{g()&&v()&&n(P)})}a(r),f(()=>{d(y,k()),d(j,b())}),c(u,r),z()}export{S as P};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
const s=globalThis.__sveltekit_13hoftk?.base??"/ui",t=globalThis.__sveltekit_13hoftk?.assets??s;export{t as a,s as b};

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
function r(t){return function(...e){var n=e[0];return n.preventDefault(),t?.apply(this,e)}}export{r as p};

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import"./DsnmJJEf.js";import{i as U}from"./B3Pzt0F_.js";import{f as I,j as t,k as p,r as a,t as P,v as b,c as u,z as N,D as A,p as W,u as z,n as H,d as X}from"./D8EpLgQ1.js";import{p as s,i as T}from"./5WA7h8uK.js";import{s as Y,h as Z,B as F,c as $}from"./CiE1LlKV.js";import{b as ee}from"./CoIRRsD9.js";import{D as te,G as ae,a as se}from"./C9DJVOi1.js";import{E as ne}from"./B7ITzBt8.js";import{S as B}from"./BE4wujub.js";var le=I('<div class="flex-shrink-0"><!></div>'),ie=I('<div class="mt-4 sm:mt-0 flex space-x-3"><!> <!></div>'),re=I('<div class="bg-white dark:bg-gray-800 shadow rounded-lg"><div class="px-4 py-5 sm:p-6"><div class="sm:flex sm:items-center sm:justify-between"><div class="flex items-center space-x-3"><!> <div><h1> </h1> <p class="text-sm text-gray-500 dark:text-gray-400"> </p></div></div> <!></div></div></div>');function ye(L,e){let n=s(e,"title",8),S=s(e,"subtitle",8),_=s(e,"forgeIcon",8,""),f=s(e,"onEdit",8,null),h=s(e,"onDelete",8,null),k=s(e,"editLabel",8,"Edit"),j=s(e,"deleteLabel",8,"Delete"),g=s(e,"titleClass",8,"");var c=re(),v=t(c),m=t(v),y=t(m),C=t(y);{var E=i=>{var r=le(),w=t(r);Z(w,_),a(r),u(i,r)};T(C,i=>{_()&&i(E)})}var l=p(C,2),D=t(l),G=t(D,!0);a(D);var M=p(D,2),V=t(M,!0);a(M),a(l),a(y);var R=p(y,2);{var q=i=>{var r=ie(),w=t(r);{var J=o=>{F(o,{variant:"secondary",size:"md",icon:"<path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z'/>",$$events:{click(...d){f()?.apply(this,d)}},children:(d,Q)=>{N();var x=A();P(()=>b(x,k())),u(d,x)},$$slots:{default:!0}})};T(w,o=>{f()&&o(J)})}var K=p(w,2);{var O=o=>{F(o,{variant:"danger",size:"md",icon:"<path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16'/>",$$events:{click(...d){h()?.apply(this,d)}},children:(d,Q)=>{N();var x=A();P(()=>b(x,j())),u(d,x)},$$slots:{default:!0}})};T(K,o=>{h()&&o(O)})}a(r),u(i,r)};T(R,i=>{(f()||h())&&i(q)})}a(m),a(v),a(c),P(()=>{Y(D,1,`text-2xl font-bold text-gray-900 dark:text-white ${g()??""}`),b(G,n()),b(V,S())}),u(L,c)}var oe=I('<div class="bg-white dark:bg-gray-800 shadow rounded-lg"><div class="px-4 py-5 sm:p-6"><div class="flex items-center justify-between mb-4"><h2 class="text-lg font-medium text-gray-900 dark:text-white"> </h2> <a class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">View all instances</a></div> <!></div></div>');function xe(L,e){W(e,!1);let n=s(e,"instances",8),S=s(e,"entityType",8),_=s(e,"onDeleteInstance",8);const f=[{key:"name",title:"Name",cellComponent:ne,cellProps:{entityType:"instance",nameField:"name"}},{key:"status",title:"Status",cellComponent:B,cellProps:{statusType:"instance",statusField:"status"}},{key:"runner_status",title:"Runner Status",cellComponent:B,cellProps:{statusType:"instance",statusField:"runner_status"}},{key:"created",title:"Created",cellComponent:ae,cellProps:{field:"created_at",type:"date"}},{key:"actions",title:"Actions",align:"right",cellComponent:se,cellProps:{actions:[{type:"delete",label:"Delete",title:"Delete instance",ariaLabel:"Delete instance",action:"delete"}]}}],h={entityType:"instance",primaryText:{field:"name",isClickable:!0,href:"/instances/{name}"},secondaryText:{field:"provider_id"},badges:[{type:"status",field:"status"}],actions:[{type:"delete",handler:l=>k(l)}]};function k(l){_()(l)}function j(l){k(l.detail.item)}U();var g=oe(),c=t(g),v=t(c),m=t(v),y=t(m);a(m);var C=p(m,2);a(v);var E=p(v,2);te(E,{get columns(){return f},get data(){return n()},loading:!1,error:"",searchTerm:"",showSearch:!1,showPagination:!1,currentPage:1,get perPage(){return H(n()),z(()=>n().length)},totalPages:1,get totalItems(){return H(n()),z(()=>n().length)},itemName:"instances",emptyTitle:"No instances running",get emptyMessage(){return`No instances running for this ${S()??""}.`},emptyIconType:"cog",get mobileCardConfig(){return h},$$events:{delete:j}}),a(c),a(g),P(()=>{b(y,`Instances (${H(n()),z(()=>n().length)??""})`),$(C,"href",`${ee}/instances`)}),u(L,g),X()}export{ye as D,xe as I};

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,4 @@
import"./DsnmJJEf.js";import{i as g}from"./B3Pzt0F_.js";import{p as k,l as x,s as d,m as w,n as y,a as J,f as m,j as z,w as j,k as L,g as c,r as B,t as C,c as n,d as E}from"./D8EpLgQ1.js";import{p as o,i as M}from"./5WA7h8uK.js";import{c as f,s as N}from"./CiE1LlKV.js";import{b as O}from"./C6k1Q4We.js";var S=m('<div class="absolute top-2 right-2"><svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path></svg></div>'),V=m('<div class="relative"><textarea style="tab-size: 2;" spellcheck="false"></textarea> <!></div>');function I(p,r){k(r,!1);let t=o(r,"value",12,""),u=o(r,"placeholder",8,"{}"),b=o(r,"rows",8,4),i=o(r,"disabled",8,!1),a=w(!0);x(()=>y(t()),()=>{if(t().trim())try{JSON.parse(t()),d(a,!0)}catch{d(a,!1)}else d(a,!0)}),J(),g();var l=V(),e=z(l);j(e);var v=L(e,2);{var h=s=>{var _=S();n(s,_)};M(v,s=>{c(a)||s(h)})}B(l),C(()=>{f(e,"placeholder",u()),f(e,"rows",b()),e.disabled=i(),N(e,1,`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono text-sm resize-none
${c(a)?"border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white":"border-red-300 dark:border-red-600 bg-red-50 dark:bg-red-900/20 text-red-900 dark:text-red-100"}
${i()?"opacity-50 cursor-not-allowed":""}
`)}),O(e,t),n(p,l),E()}export{I as J};

View file

@ -0,0 +1 @@
import"./DsnmJJEf.js";import{i as ae}from"./B3Pzt0F_.js";import{p as se,E as re,l as P,n as ie,s as r,g as t,m as k,a as le,f as p,j as v,k as U,r as f,c as l,d as oe,B as T,b as $,z as V,D as q,t as E,v as N,u as ne}from"./D8EpLgQ1.js";import{p as R,i as m}from"./5WA7h8uK.js";import{g as u,B as G}from"./CiE1LlKV.js";import{t as y}from"./BEkVdVE1.js";var de=p('<div class="flex items-center"><div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div> <span class="text-sm text-gray-500 dark:text-gray-400">Checking...</span></div>'),ce=p('<div class="ml-4 text-xs text-gray-500 dark:text-gray-400"> </div>'),ve=p('<div class="flex items-center"><svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path></svg> <span class="text-sm text-green-700 dark:text-green-300">Webhook installed</span></div> <!>',1),fe=p('<div class="flex items-center"><svg class="w-4 h-4 text-gray-400 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm0-2a6 6 0 100-12 6 6 0 000 12zm0-10a1 1 0 011 1v3a1 1 0 01-2 0V7a1 1 0 011-1z" clip-rule="evenodd"></path></svg> <span class="text-sm text-gray-500 dark:text-gray-400">No webhook installed</span></div>'),ue=p('<div class="bg-white dark:bg-gray-800 shadow rounded-lg"><div class="px-4 py-5 sm:p-6"><div class="flex items-center justify-between"><div><h3 class="text-lg font-medium text-gray-900 dark:text-white">Webhook Status</h3> <div class="mt-1 flex items-center"><!></div></div> <div class="flex space-x-2"><!></div></div></div></div>');function ye(H,g){se(g,!1);const x=k();let h=R(g,"entityType",8),s=R(g,"entityId",8),j=R(g,"entityName",8),i=k(null),o=k(!1),b=k(!0);const O=re();async function _(){if(s())try{r(b,!0),h()==="repository"?r(i,await u.getRepositoryWebhookInfo(s())):r(i,await u.getOrganizationWebhookInfo(s()))}catch(e){e&&typeof e=="object"&&"response"in e&&e.response?.status===404?r(i,null):(console.warn("Failed to check webhook status:",e),r(i,null))}finally{r(b,!1)}}async function J(){if(s())try{r(o,!0),h()==="repository"?await u.installRepositoryWebhook(s()):await u.installOrganizationWebhook(s()),y.success("Webhook Installed",`Webhook for ${h()} ${j()} has been installed successfully.`),await _(),O("webhookStatusChanged",{installed:!0})}catch(e){y.error("Webhook Installation Failed",e instanceof Error?e.message:"Failed to install webhook.")}finally{r(o,!1)}}async function K(){if(s())try{r(o,!0),h()==="repository"?await u.uninstallRepositoryWebhook(s()):await u.uninstallOrganizationWebhook(s()),y.success("Webhook Uninstalled",`Webhook for ${h()} ${j()} has been uninstalled successfully.`),await _(),O("webhookStatusChanged",{installed:!1})}catch(e){y.error("Webhook Uninstall Failed",e instanceof Error?e.message:"Failed to uninstall webhook.")}finally{r(o,!1)}}P(()=>ie(s()),()=>{s()&&_()}),P(()=>t(i),()=>{r(x,t(i)&&t(i).active)}),le(),ae();var w=ue(),A=v(w),D=v(A),W=v(D),L=U(v(W),2),Q=v(L);{var X=e=>{var d=de();l(e,d)},Y=e=>{var d=T(),z=$(d);{var I=a=>{var n=ve(),B=U($(n),2);{var c=C=>{var F=ce(),te=v(F);f(F),E(()=>N(te,`URL: ${t(i),ne(()=>t(i).url||"N/A")??""}`)),l(C,F)};m(B,C=>{t(i)&&C(c)})}l(a,n)},S=a=>{var n=fe();l(a,n)};m(z,a=>{t(x)?a(I):a(S,!1)},!0)}l(e,d)};m(Q,e=>{t(b)?e(X):e(Y,!1)})}f(L),f(W);var M=U(W,2),Z=v(M);{var ee=e=>{var d=T(),z=$(d);{var I=a=>{G(a,{variant:"danger",size:"sm",get disabled(){return t(o)},$$events:{click:K},children:(n,B)=>{V();var c=q();E(()=>N(c,t(o)?"Uninstalling...":"Uninstall")),l(n,c)},$$slots:{default:!0}})},S=a=>{G(a,{variant:"primary",size:"sm",get disabled(){return t(o)},$$events:{click:J},children:(n,B)=>{V();var c=q();E(()=>N(c,t(o)?"Installing...":"Install Webhook")),l(n,c)},$$slots:{default:!0}})};m(z,a=>{t(x)?a(I):a(S,!1)})}l(e,d)};m(Z,e=>{t(b)||e(ee)})}f(M),f(D),f(A),f(w),l(H,w),oe()}export{ye as W};

View file

@ -0,0 +1 @@
typeof window<"u"&&((window.__svelte??={}).v??=new Set).add("5");

View file

@ -0,0 +1 @@
import"./DsnmJJEf.js";import{i as q}from"./B3Pzt0F_.js";import{p as A,E as F,f as y,k as l,j as e,r as a,z as $,D as b,c as o,t as p,v as n,d as G}from"./D8EpLgQ1.js";import{p as v,i as H}from"./5WA7h8uK.js";import{M as I}from"./qB7B8uiS.js";import{B as w}from"./CiE1LlKV.js";var J=y('<p class="mt-1 font-medium text-gray-900 dark:text-white"> </p>'),K=y('<div class="max-w-xl w-full p-6"><div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900 mb-4"><svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path></svg></div> <div class="text-center"><h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-2"> </h3> <div class="text-sm text-gray-500 dark:text-gray-400"><p> </p> <!></div></div> <div class="mt-6 flex justify-end space-x-3"><!> <!></div></div>');function W(D,s){A(s,!1);let j=v(s,"title",8),M=v(s,"message",8),g=v(s,"itemName",8,""),d=v(s,"loading",8,!1);const c=F();function B(){c("confirm")}q(),I(D,{$$events:{close:()=>c("close")},children:(C,O)=>{var m=K(),f=l(e(m),2),u=e(f),P=e(u,!0);a(u);var h=l(u,2),x=e(h),z=e(x,!0);a(x);var E=l(x,2);{var L=t=>{var i=J(),r=e(i,!0);a(i),p(()=>n(r,g())),o(t,i)};H(E,t=>{g()&&t(L)})}a(h),a(f);var _=l(f,2),k=e(_);w(k,{variant:"secondary",get disabled(){return d()},$$events:{click:()=>c("close")},children:(t,i)=>{$();var r=b("Cancel");o(t,r)},$$slots:{default:!0}});var N=l(k,2);w(N,{variant:"danger",get disabled(){return d()},get loading(){return d()},$$events:{click:B},children:(t,i)=>{$();var r=b();p(()=>n(r,d()?"Deleting...":"Delete")),o(t,r)},$$slots:{default:!0}}),a(_),a(m),p(()=>{n(P,j()),n(z,M())}),o(C,m)},$$slots:{default:!0}}),G()}export{W as D};

View file

@ -0,0 +1 @@
import{I as w}from"./D8EpLgQ1.js";import{g as r}from"./CiE1LlKV.js";const m=!0,z=m,I=()=>window.location.port==="5173",b={isAuthenticated:!1,user:null,loading:!0,needsInitialization:!1},n=w(b);function f(t,a,e=7){const i=new Date;i.setTime(i.getTime()+e*24*60*60*1e3),document.cookie=`${t}=${a};expires=${i.toUTCString()};path=/;SameSite=Lax`}function d(t){const a=t+"=",e=document.cookie.split(";");for(let i=0;i<e.length;i++){let o=e[i];for(;o.charAt(0)===" ";)o=o.substring(1,o.length);if(o.indexOf(a)===0)return o.substring(a.length,o.length)}return null}function g(t){document.cookie=`${t}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/`}const c={async login(t,a){try{n.update(i=>({...i,loading:!0}));const e=await r.login({username:t,password:a});z&&(f("garm_token",e.token),f("garm_user",t)),r.setToken(e.token),n.set({isAuthenticated:!0,user:t,loading:!1,needsInitialization:!1})}catch(e){throw n.update(i=>({...i,loading:!1})),e}},logout(){g("garm_token"),g("garm_user"),n.set({isAuthenticated:!1,user:null,loading:!1,needsInitialization:!1})},async init(){try{n.update(e=>({...e,loading:!0})),await c.checkInitializationStatus();const t=d("garm_token"),a=d("garm_user");if(t&&a&&(r.setToken(t),await c.checkAuth())){n.set({isAuthenticated:!0,user:a,loading:!1,needsInitialization:!1});return}n.update(e=>({...e,loading:!1,needsInitialization:!1}))}catch{n.update(a=>({...a,loading:!1}))}},async checkInitializationStatus(){try{const t={Accept:"application/json"},a=d("garm_token"),e=I();e&&a&&(t.Authorization=`Bearer ${a}`);const i=await fetch("/api/v1/login",{method:"GET",headers:t,credentials:e?"omit":"include"});if(!i.ok){if(i.status===409&&(await i.json()).error==="init_required")throw n.update(s=>({...s,needsInitialization:!0,loading:!1})),new Error("Initialization required");return}return}catch(t){if(t instanceof Error&&t.message==="Initialization required")throw t;return}},async checkAuth(){try{return await c.checkInitializationStatus(),await r.getControllerInfo(),!0}catch(t){return t instanceof Error&&t.message==="Initialization required"?!1:t?.response?.status===409&&t?.response?.data?.error==="init_required"?(n.update(a=>({...a,needsInitialization:!0,loading:!1})),!1):(c.logout(),!1)}},async initialize(t,a,e,i,o){try{n.update(u=>({...u,loading:!0}));const s=await r.firstRun({username:t,email:a,password:e,full_name:i||t});await c.login(t,e);const l=window.location.origin,h=o?.metadataUrl||`${l}/api/v1/metadata`,p=o?.callbackUrl||`${l}/api/v1/callbacks`,k=o?.webhookUrl||`${l}/webhooks`;await r.updateController({metadata_url:h,callback_url:p,webhook_url:k}),n.update(u=>({...u,needsInitialization:!1}))}catch(s){throw n.update(l=>({...l,loading:!1})),s}}};export{n as a,c as b};

View file

@ -0,0 +1 @@
function a(e){return e?e.replace(/_/g," ").toLowerCase().split(" ").map(r=>r.charAt(0).toUpperCase()+r.slice(1)).join(" "):""}function g(e){if(!e)return"bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20";switch(e.toLowerCase()){case"running":case"online":return"bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20";case"idle":case"stopped":return"bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-500/10 dark:text-blue-400 dark:ring-blue-500/20";case"active":return"bg-yellow-50 text-yellow-700 ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20";case"creating":case"installing":case"pending_create":case"provisioning":return"bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-500/10 dark:text-purple-400 dark:ring-purple-500/20 animate-pulse";case"deleting":case"terminating":case"pending_delete":case"destroying":return"bg-orange-50 text-orange-700 ring-orange-600/20 dark:bg-orange-500/10 dark:text-orange-400 dark:ring-orange-500/20 animate-pulse";case"failed":case"error":case"terminated":case"offline":return"bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:ring-red-500/20";case"pending":case"unknown":return"bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20 animate-pulse";default:return"bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20"}}export{a as f,g};

View file

@ -0,0 +1 @@
import"./DsnmJJEf.js";import{i as u}from"./B3Pzt0F_.js";import{p as v,E as m,f as h,j as r,r as d,e as t,c as k,d as g}from"./D8EpLgQ1.js";import{d as b}from"./CiE1LlKV.js";var w=h('<div class="fixed inset-0 bg-black/30 dark:bg-black/50 overflow-y-auto h-full w-full z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" tabindex="-1"><div class="relative mx-auto bg-white dark:bg-gray-800 rounded-lg shadow-lg" role="document"><!></div></div>');function j(s,i){v(i,!1);const l=m();function n(){l("close")}function c(o){o.stopPropagation()}function f(o){o.key==="Escape"&&l("close")}u();var a=w(),e=r(a),p=r(e);b(p,i,"default",{}),d(e),d(a),t("click",e,c),t("click",a,n),t("keydown",a,f),k(s,a),g()}export{j as M};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{l as o,a as r}from"../chunks/CTf6mQoE.js";export{o as load_css,r as start};

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import"../chunks/DsnmJJEf.js";import{i as u}from"../chunks/B3Pzt0F_.js";import{p as h,f as g,b as v,t as d,c as l,d as _,j as s,r as a,k as x,v as o}from"../chunks/D8EpLgQ1.js";import{s as k,p}from"../chunks/CTf6mQoE.js";const $={get error(){return p.error},get status(){return p.status}};k.updated.check;const i=$;var b=g("<h1> </h1> <p> </p>",1);function y(m,c){h(c,!1),u();var r=b(),t=v(r),n=s(t,!0);a(t);var e=x(t,2),f=s(e,!0);a(e),d(()=>{o(n,i.status),o(f,i.error?.message)}),l(m,r),_()}export{y as component};

View file

@ -0,0 +1 @@
import"../chunks/DsnmJJEf.js";import{i as X}from"../chunks/B3Pzt0F_.js";import{p as Y,o as Z,l as ee,a as ae,f as H,h as re,t as _,g as a,e as k,c as w,d as te,$ as se,k as d,D as de,m as f,j as r,s as i,r as t,z as B,v as D}from"../chunks/D8EpLgQ1.js";import{i as oe,s as ie,a as le}from"../chunks/5WA7h8uK.js";import{B as ne,r as q,c as T}from"../chunks/CiE1LlKV.js";import{b as U}from"../chunks/C6k1Q4We.js";import{p as ce}from"../chunks/D4Caz1gY.js";import{g as C}from"../chunks/CTf6mQoE.js";import{b as c}from"../chunks/CoIRRsD9.js";import{a as me,b as ue}from"../chunks/duD3WMbl.js";var pe=H('<div class="rounded-md bg-red-50 dark:bg-red-900 p-4"><div class="flex"><div class="flex-shrink-0"><svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path></svg></div> <div class="ml-3"><p class="text-sm font-medium text-red-800 dark:text-red-200"> </p></div></div></div>'),ve=H('<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8"><div class="max-w-md w-full space-y-8"><div><div class="mx-auto h-48 w-auto flex justify-center"><img alt="GARM" class="h-48 w-auto dark:hidden"/> <img alt="GARM" class="h-48 w-auto hidden dark:block"/></div> <h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">Sign in to GARM</h2> <p class="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">GitHub Actions Runner Manager</p></div> <form class="mt-8 space-y-6"><div class="rounded-md shadow-sm -space-y-px"><div><label for="username" class="sr-only">Username</label> <input id="username" name="username" type="text" required class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-700 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" placeholder="Username"/></div> <div><label for="password" class="sr-only">Password</label> <input id="password" name="password" type="password" required class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-700 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" placeholder="Password"/></div></div> <!> <div><!></div></form></div></div>');function Le(I,K){Y(K,!1);const[W,F]=ie(),$=()=>le(me,"$authStore",W);let m=f(""),u=f(""),o=f(!1),l=f("");Z(()=>{J()});function J(){const e=localStorage.getItem("theme");let s=!1;e==="dark"?s=!0:e==="light"?s=!1:s=window.matchMedia("(prefers-color-scheme: dark)").matches,s?document.documentElement.classList.add("dark"):document.documentElement.classList.remove("dark")}async function L(){if(!a(m)||!a(u)){i(l,"Please enter both username and password");return}i(o,!0),i(l,"");try{await ue.login(a(m),a(u)),C(`${c}/`)}catch(e){i(l,e instanceof Error?e.message:"Login failed")}finally{i(o,!1)}}function M(e){e.key==="Enter"&&L()}ee(()=>($(),c),()=>{$().isAuthenticated&&C(`${c}/`)}),ae(),X();var g=ve();re(e=>{se.title="Login - GARM"});var z=r(g),h=r(z),S=r(h),A=r(S),N=d(A,2);t(S),B(4),t(h);var b=d(h,2),x=r(b),y=r(x),p=d(r(y),2);q(p),t(y);var G=d(y,2),v=d(r(G),2);q(v),t(G),t(x);var P=d(x,2);{var O=e=>{var s=pe(),n=r(s),E=d(r(n),2),j=r(E),V=r(j,!0);t(j),t(E),t(n),t(s),_(()=>D(V,a(l))),w(e,s)};oe(P,e=>{a(l)&&e(O)})}var R=d(P,2),Q=r(R);ne(Q,{type:"submit",variant:"primary",size:"md",fullWidth:!0,get disabled(){return a(o)},get loading(){return a(o)},children:(e,s)=>{B();var n=de();_(()=>D(n,a(o)?"Signing in...":"Sign in")),w(e,n)},$$slots:{default:!0}}),t(R),t(b),t(z),t(g),_(()=>{T(A,"src",`${c??""}/assets/garm-light.svg`),T(N,"src",`${c??""}/assets/garm-dark.svg`),p.disabled=a(o),v.disabled=a(o)}),U(p,()=>a(m),e=>i(m,e)),k("keypress",p,M),U(v,()=>a(u),e=>i(u,e)),k("keypress",v,M),k("submit",b,ce(L)),w(I,g),te(),F()}export{Le as component};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"version":"1755334486454"}

83
webapp/assets/assets.go Normal file
View file

@ -0,0 +1,83 @@
package assets
import (
"embed"
"net/http"
"path/filepath"
"strings"
)
//go:generate go run github.com/go-swagger/go-swagger/cmd/swagger@v0.31.0 generate spec --output=../swagger.yaml --scan-models --work-dir=../../
//go:generate go run github.com/go-swagger/go-swagger/cmd/swagger@v0.31.0 validate ../swagger.yaml
//go:generate rm -rf ../src/lib/api/generated
//go:generate openapi-generator-cli generate --skip-validate-spec -i ../swagger.yaml -g typescript-axios -o ../src/lib/api/generated
//go:embed all:*
var EmbeddedSPA embed.FS
// GetSPAFileSystem returns the embedded SPA file system for use with http.FileServer
func GetSPAFileSystem() http.FileSystem {
return http.FS(EmbeddedSPA)
}
// ServeSPA serves the embedded SPA with proper content types and SPA routing
// This is kept for backward compatibility
func ServeSPA(w http.ResponseWriter, r *http.Request) {
ServeSPAWithPath(w, r, "/ui/")
}
// ServeSPAWithPath serves the embedded SPA with a custom webapp path
func ServeSPAWithPath(w http.ResponseWriter, r *http.Request, webappPath string) {
filename := strings.TrimPrefix(r.URL.Path, webappPath)
// Handle root path and SPA routing - serve index.html for all routes
if filename == "" || !strings.Contains(filename, ".") {
filename = "index.html"
}
// Security check - prevent directory traversal
if strings.Contains(filename, "..") {
http.NotFound(w, r)
return
}
// Read file from embedded filesystem
content, err := EmbeddedSPA.ReadFile(filename)
if err != nil {
// If file not found, serve index.html for SPA routing
content, err = EmbeddedSPA.ReadFile("index.html")
if err != nil {
http.NotFound(w, r)
return
}
filename = "index.html"
}
// Set appropriate content type based on file extension
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".html":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
case ".js":
w.Header().Set("Content-Type", "application/javascript")
case ".css":
w.Header().Set("Content-Type", "text/css")
case ".json":
w.Header().Set("Content-Type", "application/json")
case ".svg":
w.Header().Set("Content-Type", "image/svg+xml")
case ".png":
w.Header().Set("Content-Type", "image/png")
default:
w.Header().Set("Content-Type", "text/plain")
}
// Set cache headers for static assets (but not for HTML to ensure fresh content)
if ext != ".html" {
w.Header().Set("Cache-Control", "public, max-age=3600")
} else {
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
}
w.Write(content)
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640" width="32" height="32"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" style="fill:#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" style="fill:#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" style="fill:#609926"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1 @@
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 960 B

View file

@ -0,0 +1 @@
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>

After

Width:  |  Height:  |  Size: 963 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

105
webapp/assets/index.html Normal file
View file

@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- Light theme icon -->
<link rel="icon" href="/ui/favicon-light.png" media="(prefers-color-scheme: light)" id="favicon-light" />
<!-- Dark theme icon -->
<link rel="icon" href="/ui/favicon-dark.png" media="(prefers-color-scheme: dark)" id="favicon-dark" />
<!-- Fallback favicon -->
<link rel="icon" href="/ui/favicon-light.png" id="favicon-fallback" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
// Theme management - apply system theme or saved preference
(function() {
function applyTheme() {
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Determine if we should use dark mode
const useDarkMode = savedTheme === 'dark' ||
(!savedTheme && prefersDark) ||
(savedTheme === 'system' && prefersDark);
// Apply the theme class to document
if (useDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
function updateFavicon() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedTheme = localStorage.getItem('theme');
const useDarkMode = savedTheme === 'dark' ||
(!savedTheme && prefersDark) ||
(savedTheme === 'system' && prefersDark);
const fallbackIcon = document.getElementById('favicon-fallback');
if (useDarkMode) {
fallbackIcon.href = '/ui/favicon-dark.png';
} else {
fallbackIcon.href = '/ui/favicon-light.png';
}
}
// Apply theme and favicon on load
applyTheme();
updateFavicon();
// Listen for system theme changes
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = function(e) {
const savedTheme = localStorage.getItem('theme');
// Only update if using system theme (no saved preference or explicit 'system')
if (!savedTheme || savedTheme === 'system') {
applyTheme();
updateFavicon();
}
};
// Modern browsers
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange);
} else {
// Older browsers
mediaQuery.addListener(handleChange);
}
}
})();
</script>
<link rel="modulepreload" href="/ui/_app/immutable/entry/start.CI0Cdear.js">
<link rel="modulepreload" href="/ui/_app/immutable/chunks/CTf6mQoE.js">
<link rel="modulepreload" href="/ui/_app/immutable/chunks/D8EpLgQ1.js">
<link rel="modulepreload" href="/ui/_app/immutable/chunks/CoIRRsD9.js">
<link rel="modulepreload" href="/ui/_app/immutable/entry/app.kAVAdeq9.js">
<link rel="modulepreload" href="/ui/_app/immutable/chunks/DsnmJJEf.js">
<link rel="modulepreload" href="/ui/_app/immutable/chunks/5WA7h8uK.js">
<link rel="modulepreload" href="/ui/_app/immutable/chunks/CCSWcuVN.js">
<link rel="modulepreload" href="/ui/_app/immutable/chunks/BAg1iRPq.js">
</head>
<body data-sveltekit-preload-data="hover" class="bg-gray-100 dark:bg-gray-900">
<div style="display: contents">
<script>
{
__sveltekit_13hoftk = {
base: "/ui",
assets: "/ui"
};
const element = document.currentScript.parentElement;
Promise.all([
import("/ui/_app/immutable/entry/start.CI0Cdear.js"),
import("/ui/_app/immutable/entry/app.kAVAdeq9.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

View file

@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.14.0"
}
}

7
webapp/openapitools.json Normal file
View file

@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.14.0"
}
}

5603
webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
webapp/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "garm-webapp",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "NODE_ENV=development vite dev --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.21.4",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^6.1.0",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/postcss": "^4.1.11",
"@types/node": "^24.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"svelte": "^5.38.0",
"svelte-check": "^4.3.1",
"swagger-typescript-api": "^13.2.7",
"tailwindcss": "^4.1.11",
"typescript": "^5.0.0",
"vite": "^7.1.1"
},
"type": "module",
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.1",
"@tailwindcss/typography": "^0.5.10",
"codemirror": "^6.0.2"
},
"description": "",
"main": "postcss.config.js",
"keywords": [],
"author": "",
"license": "ISC"
}

6
webapp/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {}
}
};

18
webapp/src/app.css Normal file
View file

@ -0,0 +1,18 @@
@import "tailwindcss";
@theme {
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
}
/* Configure dark mode to use class strategy in Tailwind v4 */
@variant dark (.dark &);
@layer base {
html {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
}
}

10
webapp/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
declare global {
namespace App {
interface Error {}
interface Locals {}
interface PageData {}
interface Platform {}
}
}
export {};

78
webapp/src/app.html Normal file
View file

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- Light theme icon -->
<link rel="icon" href="%sveltekit.assets%/favicon-light.png" media="(prefers-color-scheme: light)" id="favicon-light" />
<!-- Dark theme icon -->
<link rel="icon" href="%sveltekit.assets%/favicon-dark.png" media="(prefers-color-scheme: dark)" id="favicon-dark" />
<!-- Fallback favicon -->
<link rel="icon" href="%sveltekit.assets%/favicon-light.png" id="favicon-fallback" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
// Theme management - apply system theme or saved preference
(function() {
function applyTheme() {
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Determine if we should use dark mode
const useDarkMode = savedTheme === 'dark' ||
(!savedTheme && prefersDark) ||
(savedTheme === 'system' && prefersDark);
// Apply the theme class to document
if (useDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
function updateFavicon() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedTheme = localStorage.getItem('theme');
const useDarkMode = savedTheme === 'dark' ||
(!savedTheme && prefersDark) ||
(savedTheme === 'system' && prefersDark);
const fallbackIcon = document.getElementById('favicon-fallback');
if (useDarkMode) {
fallbackIcon.href = '%sveltekit.assets%/favicon-dark.png';
} else {
fallbackIcon.href = '%sveltekit.assets%/favicon-light.png';
}
}
// Apply theme and favicon on load
applyTheme();
updateFavicon();
// Listen for system theme changes
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = function(e) {
const savedTheme = localStorage.getItem('theme');
// Only update if using system theme (no saved preference or explicit 'system')
if (!savedTheme || savedTheme === 'system') {
applyTheme();
updateFavicon();
}
};
// Modern browsers
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange);
} else {
// Older browsers
mediaQuery.addListener(handleChange);
}
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-gray-100 dark:bg-gray-900">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,77 @@
// Importing from the generated client wrapper
import {
GeneratedGarmApiClient,
type Repository,
type Organization,
type Enterprise,
type Endpoint,
type Pool,
type ScaleSet,
type Instance,
type ForgeCredentials,
type Provider,
type ControllerInfo,
type CreateRepoParams,
type CreateOrgParams,
type CreateEnterpriseParams,
type CreatePoolParams,
type CreateScaleSetParams,
type UpdateEntityParams,
type UpdatePoolParams,
type LoginRequest,
type LoginResponse,
} from './generated-client.js';
// Import endpoint and credentials types directly
import type {
CreateGithubEndpointParams as CreateEndpointParams,
UpdateGithubEndpointParams as UpdateEndpointParams,
CreateGithubCredentialsParams as CreateCredentialsParams,
UpdateGithubCredentialsParams as UpdateCredentialsParams,
} from './generated/api';
// Re-export types for compatibility
export type {
Repository,
Organization,
Enterprise,
Endpoint,
Pool,
ScaleSet,
Instance,
ForgeCredentials,
Provider,
ControllerInfo,
CreateRepoParams,
CreateOrgParams,
CreateEnterpriseParams,
CreateEndpointParams,
UpdateEndpointParams,
CreateCredentialsParams,
UpdateCredentialsParams,
CreatePoolParams,
CreateScaleSetParams,
UpdateEntityParams,
UpdatePoolParams,
LoginRequest,
LoginResponse,
};
// Legacy APIError type for backward compatibility
export interface APIError {
error: string;
details?: string;
}
// GarmApiClient now extends/wraps the generated client
export class GarmApiClient extends GeneratedGarmApiClient {
constructor(baseUrl: string = '') {
super(baseUrl);
}
// All methods are inherited from GeneratedGarmApiClient
// This class now acts as a simple wrapper for backward compatibility
}
// Create a singleton instance
export const garmApi = new GarmApiClient();

View file

@ -0,0 +1,596 @@
// Generated API Client Wrapper for GARM
// This wraps the auto-generated OpenAPI client to match our existing interface
import {
LoginApi,
ControllerInfoApi,
ControllerApi,
EndpointsApi,
CredentialsApi,
RepositoriesApi,
OrganizationsApi,
EnterprisesApi,
PoolsApi,
ScalesetsApi,
InstancesApi,
ProvidersApi,
FirstRunApi,
HooksApi,
type Repository,
type Organization,
type Enterprise,
type ForgeEndpoint,
type Pool,
type ScaleSet,
type Instance,
type ForgeCredentials,
type Provider,
type ControllerInfo,
type CreateRepoParams,
type CreateOrgParams,
type CreateEnterpriseParams,
type CreateGithubEndpointParams,
type CreateGiteaEndpointParams,
type UpdateGithubEndpointParams,
type UpdateGiteaEndpointParams,
type CreateGithubCredentialsParams,
type CreateGiteaCredentialsParams,
type UpdateGithubCredentialsParams,
type UpdateGiteaCredentialsParams,
type CreatePoolParams,
type CreateScaleSetParams,
type UpdateEntityParams,
type UpdatePoolParams,
type PasswordLoginParams,
type JWTResponse,
type NewUserParams,
type User,
type UpdateControllerParams,
type HookInfo,
Configuration
} from './generated/index';
// Re-export types for compatibility
export type {
Repository,
Organization,
Enterprise,
ForgeEndpoint as Endpoint,
Pool,
ScaleSet,
Instance,
ForgeCredentials,
Provider,
ControllerInfo,
CreateRepoParams,
CreateOrgParams,
CreateEnterpriseParams,
CreateGithubEndpointParams as CreateEndpointParams,
UpdateGithubEndpointParams as UpdateEndpointParams,
CreateGithubCredentialsParams as CreateCredentialsParams,
UpdateGithubCredentialsParams as UpdateCredentialsParams,
CreatePoolParams,
CreateScaleSetParams,
UpdateEntityParams,
UpdatePoolParams,
PasswordLoginParams,
JWTResponse,
NewUserParams,
User,
UpdateControllerParams,
};
// Define common request types for compatibility
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
}
export class GeneratedGarmApiClient {
private baseUrl: string;
private token?: string;
private config: Configuration;
// Check if we're in development mode (cross-origin setup)
private isDevelopmentMode(): boolean {
if (typeof window === 'undefined') return false;
// Development mode: either VITE_GARM_API_URL is set OR we detect cross-origin
return !!(import.meta.env.VITE_GARM_API_URL) || window.location.port === '5173';
}
// Generated API client instances
private loginApi: LoginApi;
private controllerInfoApi: ControllerInfoApi;
private controllerApi: ControllerApi;
private endpointsApi: EndpointsApi;
private credentialsApi: CredentialsApi;
private repositoriesApi: RepositoriesApi;
private organizationsApi: OrganizationsApi;
private enterprisesApi: EnterprisesApi;
private poolsApi: PoolsApi;
private scaleSetsApi: ScalesetsApi;
private instancesApi: InstancesApi;
private providersApi: ProvidersApi;
private firstRunApi: FirstRunApi;
private hooksApi: HooksApi;
constructor(baseUrl: string = '') {
this.baseUrl = baseUrl || window.location.origin;
// Create configuration for the generated client
const isDevMode = this.isDevelopmentMode();
this.config = new Configuration({
basePath: `${this.baseUrl}/api/v1`,
accessToken: () => this.token || '',
baseOptions: {
// In development mode, don't send cookies (use Bearer token only)
// In production mode, include cookies for authentication
withCredentials: !isDevMode,
},
});
// Initialize generated API clients
this.loginApi = new LoginApi(this.config);
this.controllerInfoApi = new ControllerInfoApi(this.config);
this.controllerApi = new ControllerApi(this.config);
this.endpointsApi = new EndpointsApi(this.config);
this.credentialsApi = new CredentialsApi(this.config);
this.repositoriesApi = new RepositoriesApi(this.config);
this.organizationsApi = new OrganizationsApi(this.config);
this.enterprisesApi = new EnterprisesApi(this.config);
this.poolsApi = new PoolsApi(this.config);
this.scaleSetsApi = new ScalesetsApi(this.config);
this.instancesApi = new InstancesApi(this.config);
this.providersApi = new ProvidersApi(this.config);
this.firstRunApi = new FirstRunApi(this.config);
this.hooksApi = new HooksApi(this.config);
}
// Set authentication token
setToken(token: string) {
this.token = token;
// Update configuration for all clients
const isDevMode = this.isDevelopmentMode();
this.config = new Configuration({
basePath: `${this.baseUrl}/api/v1`,
accessToken: () => token,
baseOptions: {
// In development mode, don't send cookies (use Bearer token only)
// In production mode, include cookies for authentication
withCredentials: !isDevMode,
},
});
// Recreate all API instances with new config
this.loginApi = new LoginApi(this.config);
this.controllerInfoApi = new ControllerInfoApi(this.config);
this.controllerApi = new ControllerApi(this.config);
this.endpointsApi = new EndpointsApi(this.config);
this.credentialsApi = new CredentialsApi(this.config);
this.repositoriesApi = new RepositoriesApi(this.config);
this.organizationsApi = new OrganizationsApi(this.config);
this.enterprisesApi = new EnterprisesApi(this.config);
this.poolsApi = new PoolsApi(this.config);
this.scaleSetsApi = new ScalesetsApi(this.config);
this.instancesApi = new InstancesApi(this.config);
this.providersApi = new ProvidersApi(this.config);
this.firstRunApi = new FirstRunApi(this.config);
this.hooksApi = new HooksApi(this.config);
}
// Authentication
async login(credentials: LoginRequest): Promise<LoginResponse> {
const params: PasswordLoginParams = {
username: credentials.username,
password: credentials.password,
};
const response = await this.loginApi.login(params);
const token = response.data.token;
if (token) {
this.setToken(token);
return { token };
}
throw new Error('Login failed');
}
async getControllerInfo(): Promise<ControllerInfo> {
const response = await this.controllerInfoApi.controllerInfo();
return response.data;
}
// GitHub Endpoints
async listGithubEndpoints(): Promise<ForgeEndpoint[]> {
const response = await this.endpointsApi.listGithubEndpoints();
return response.data || [];
}
async getGithubEndpoint(name: string): Promise<ForgeEndpoint> {
const response = await this.endpointsApi.getGithubEndpoint(name);
return response.data;
}
async createGithubEndpoint(params: CreateGithubEndpointParams): Promise<ForgeEndpoint> {
const response = await this.endpointsApi.createGithubEndpoint(params);
return response.data;
}
async updateGithubEndpoint(name: string, params: UpdateGithubEndpointParams): Promise<ForgeEndpoint> {
const response = await this.endpointsApi.updateGithubEndpoint(name, params);
return response.data;
}
async deleteGithubEndpoint(name: string): Promise<void> {
await this.endpointsApi.deleteGithubEndpoint(name);
}
// Gitea Endpoints
async listGiteaEndpoints(): Promise<ForgeEndpoint[]> {
const response = await this.endpointsApi.listGiteaEndpoints();
return response.data || [];
}
async getGiteaEndpoint(name: string): Promise<ForgeEndpoint> {
const response = await this.endpointsApi.getGiteaEndpoint(name);
return response.data;
}
async createGiteaEndpoint(params: CreateGiteaEndpointParams): Promise<ForgeEndpoint> {
const response = await this.endpointsApi.createGiteaEndpoint(params);
return response.data;
}
async updateGiteaEndpoint(name: string, params: UpdateGiteaEndpointParams): Promise<ForgeEndpoint> {
const response = await this.endpointsApi.updateGiteaEndpoint(name, params);
return response.data;
}
async deleteGiteaEndpoint(name: string): Promise<void> {
await this.endpointsApi.deleteGiteaEndpoint(name);
}
// Combined Endpoints helper
async listAllEndpoints(): Promise<ForgeEndpoint[]> {
const [githubEndpoints, giteaEndpoints] = await Promise.all([
this.listGithubEndpoints().catch(() => []),
this.listGiteaEndpoints().catch(() => [])
]);
return [
...githubEndpoints.map(ep => ({ ...ep, endpoint_type: 'github' as const })),
...giteaEndpoints.map(ep => ({ ...ep, endpoint_type: 'gitea' as const }))
];
}
// GitHub Credentials
async listGithubCredentials(): Promise<ForgeCredentials[]> {
const response = await this.credentialsApi.listCredentials();
return response.data || [];
}
async getGithubCredentials(id: number): Promise<ForgeCredentials> {
const response = await this.credentialsApi.getCredentials(id);
return response.data;
}
async createGithubCredentials(params: CreateGithubCredentialsParams): Promise<ForgeCredentials> {
const response = await this.credentialsApi.createCredentials(params);
return response.data;
}
async updateGithubCredentials(id: number, params: UpdateGithubCredentialsParams): Promise<ForgeCredentials> {
const response = await this.credentialsApi.updateCredentials(id, params);
return response.data;
}
async deleteGithubCredentials(id: number): Promise<void> {
await this.credentialsApi.deleteCredentials(id);
}
// Gitea Credentials
async listGiteaCredentials(): Promise<ForgeCredentials[]> {
const response = await this.credentialsApi.listGiteaCredentials();
return response.data || [];
}
async getGiteaCredentials(id: number): Promise<ForgeCredentials> {
const response = await this.credentialsApi.getGiteaCredentials(id);
return response.data;
}
async createGiteaCredentials(params: CreateGiteaCredentialsParams): Promise<ForgeCredentials> {
const response = await this.credentialsApi.createGiteaCredentials(params);
return response.data;
}
async updateGiteaCredentials(id: number, params: UpdateGiteaCredentialsParams): Promise<ForgeCredentials> {
const response = await this.credentialsApi.updateGiteaCredentials(id, params);
return response.data;
}
async deleteGiteaCredentials(id: number): Promise<void> {
await this.credentialsApi.deleteGiteaCredentials(id);
}
// Combined Credentials helper
async listAllCredentials(): Promise<ForgeCredentials[]> {
const [githubCredentials, giteaCredentials] = await Promise.all([
this.listGithubCredentials().catch(() => []),
this.listGiteaCredentials().catch(() => [])
]);
return [...githubCredentials, ...giteaCredentials];
}
// Repositories
async installRepositoryWebhook(repoId: string, params: any = {}): Promise<void> {
await this.repositoriesApi.installRepoWebhook(repoId, params);
}
async uninstallRepositoryWebhook(repoId: string): Promise<void> {
await this.hooksApi.uninstallRepoWebhook(repoId);
}
async getRepositoryWebhookInfo(repoId: string): Promise<HookInfo> {
const response = await this.hooksApi.getRepoWebhookInfo(repoId);
return response.data;
}
async listRepositories(): Promise<Repository[]> {
const response = await this.repositoriesApi.listRepos();
return response.data || [];
}
async getRepository(id: string): Promise<Repository> {
const response = await this.repositoriesApi.getRepo(id);
return response.data;
}
async createRepository(params: CreateRepoParams): Promise<Repository> {
const response = await this.repositoriesApi.createRepo(params);
return response.data;
}
async updateRepository(id: string, params: UpdateEntityParams): Promise<Repository> {
const response = await this.repositoriesApi.updateRepo(id, params);
return response.data;
}
async deleteRepository(id: string): Promise<void> {
await this.repositoriesApi.deleteRepo(id);
}
async installRepoWebhook(id: string): Promise<void> {
await this.repositoriesApi.installRepoWebhook(id, {});
}
async listRepositoryPools(id: string): Promise<Pool[]> {
const response = await this.repositoriesApi.listRepoPools(id);
return response.data || [];
}
async listRepositoryInstances(id: string): Promise<Instance[]> {
const response = await this.repositoriesApi.listRepoInstances(id);
return response.data || [];
}
async createRepositoryPool(id: string, params: CreatePoolParams): Promise<Pool> {
const response = await this.repositoriesApi.createRepoPool(id, params);
return response.data;
}
// Organizations
async installOrganizationWebhook(orgId: string, params: any = {}): Promise<void> {
await this.organizationsApi.installOrgWebhook(orgId, params);
}
async uninstallOrganizationWebhook(orgId: string): Promise<void> {
await this.hooksApi.uninstallOrgWebhook(orgId);
}
async getOrganizationWebhookInfo(orgId: string): Promise<HookInfo> {
const response = await this.hooksApi.getOrgWebhookInfo(orgId);
return response.data;
}
async listOrganizations(): Promise<Organization[]> {
const response = await this.organizationsApi.listOrgs();
return response.data || [];
}
async getOrganization(id: string): Promise<Organization> {
const response = await this.organizationsApi.getOrg(id);
return response.data;
}
async createOrganization(params: CreateOrgParams): Promise<Organization> {
const response = await this.organizationsApi.createOrg(params);
return response.data;
}
async updateOrganization(id: string, params: UpdateEntityParams): Promise<Organization> {
const response = await this.organizationsApi.updateOrg(id, params);
return response.data;
}
async deleteOrganization(id: string): Promise<void> {
await this.organizationsApi.deleteOrg(id);
}
async listOrganizationPools(id: string): Promise<Pool[]> {
const response = await this.organizationsApi.listOrgPools(id);
return response.data || [];
}
async listOrganizationInstances(id: string): Promise<Instance[]> {
const response = await this.organizationsApi.listOrgInstances(id);
return response.data || [];
}
async createOrganizationPool(id: string, params: CreatePoolParams): Promise<Pool> {
const response = await this.organizationsApi.createOrgPool(id, params);
return response.data;
}
// Enterprises
async listEnterprises(): Promise<Enterprise[]> {
const response = await this.enterprisesApi.listEnterprises();
return response.data || [];
}
async getEnterprise(id: string): Promise<Enterprise> {
const response = await this.enterprisesApi.getEnterprise(id);
return response.data;
}
async createEnterprise(params: CreateEnterpriseParams): Promise<Enterprise> {
const response = await this.enterprisesApi.createEnterprise(params);
return response.data;
}
async updateEnterprise(id: string, params: UpdateEntityParams): Promise<Enterprise> {
const response = await this.enterprisesApi.updateEnterprise(id, params);
return response.data;
}
async deleteEnterprise(id: string): Promise<void> {
await this.enterprisesApi.deleteEnterprise(id);
}
async listEnterprisePools(id: string): Promise<Pool[]> {
const response = await this.enterprisesApi.listEnterprisePools(id);
return response.data || [];
}
async listEnterpriseInstances(id: string): Promise<Instance[]> {
const response = await this.enterprisesApi.listEnterpriseInstances(id);
return response.data || [];
}
async createEnterprisePool(id: string, params: CreatePoolParams): Promise<Pool> {
const response = await this.enterprisesApi.createEnterprisePool(id, params);
return response.data;
}
// Scale sets for repositories, organizations, and enterprises
async createRepositoryScaleSet(id: string, params: CreateScaleSetParams): Promise<ScaleSet> {
const response = await this.repositoriesApi.createRepoScaleSet(id, params);
return response.data;
}
async listRepositoryScaleSets(id: string): Promise<ScaleSet[]> {
const response = await this.repositoriesApi.listRepoScaleSets(id);
return response.data || [];
}
async createOrganizationScaleSet(id: string, params: CreateScaleSetParams): Promise<ScaleSet> {
const response = await this.organizationsApi.createOrgScaleSet(id, params);
return response.data;
}
async listOrganizationScaleSets(id: string): Promise<ScaleSet[]> {
const response = await this.organizationsApi.listOrgScaleSets(id);
return response.data || [];
}
async createEnterpriseScaleSet(id: string, params: CreateScaleSetParams): Promise<ScaleSet> {
const response = await this.enterprisesApi.createEnterpriseScaleSet(id, params);
return response.data;
}
async listEnterpriseScaleSets(id: string): Promise<ScaleSet[]> {
const response = await this.enterprisesApi.listEnterpriseScaleSets(id);
return response.data || [];
}
// Pools
async listPools(): Promise<Pool[]> {
const response = await this.poolsApi.listPools();
return response.data || [];
}
async listAllPools(): Promise<Pool[]> {
return this.listPools();
}
async getPool(id: string): Promise<Pool> {
const response = await this.poolsApi.getPool(id);
return response.data;
}
async updatePool(id: string, params: UpdatePoolParams): Promise<Pool> {
const response = await this.poolsApi.updatePool(id, params);
return response.data;
}
async deletePool(id: string): Promise<void> {
await this.poolsApi.deletePool(id);
}
// Scale Sets
async listScaleSets(): Promise<ScaleSet[]> {
const response = await this.scaleSetsApi.listScalesets();
return response.data || [];
}
async getScaleSet(id: number): Promise<ScaleSet> {
const response = await this.scaleSetsApi.getScaleSet(id.toString());
return response.data;
}
async updateScaleSet(id: number, params: Partial<CreateScaleSetParams>): Promise<ScaleSet> {
const response = await this.scaleSetsApi.updateScaleSet(id.toString(), params);
return response.data;
}
async deleteScaleSet(id: number): Promise<void> {
await this.scaleSetsApi.deleteScaleSet(id.toString());
}
// Instances
async listInstances(): Promise<Instance[]> {
const response = await this.instancesApi.listInstances();
return response.data || [];
}
async getInstance(name: string): Promise<Instance> {
const response = await this.instancesApi.getInstance(name);
return response.data;
}
async deleteInstance(name: string): Promise<void> {
await this.instancesApi.deleteInstance(name);
}
// Providers
async listProviders(): Promise<Provider[]> {
const response = await this.providersApi.listProviders();
return response.data || [];
}
// Compatibility aliases
async listCredentials(): Promise<ForgeCredentials[]> {
return this.listAllCredentials();
}
async listEndpoints(): Promise<ForgeEndpoint[]> {
return this.listAllEndpoints();
}
// First-run initialization
async firstRun(params: NewUserParams): Promise<User> {
const response = await this.firstRunApi.firstRun(params);
return response.data;
}
// Controller management
async updateController(params: UpdateControllerParams): Promise<ControllerInfo> {
const response = await this.controllerApi.updateController(params);
return response.data;
}
}
// Create a singleton instance
export const generatedGarmApi = new GeneratedGarmApiClient();

View file

@ -0,0 +1,4 @@
wwwroot/*.js
node_modules
typings
dist

View file

@ -0,0 +1 @@
# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm

View file

@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View file

@ -0,0 +1,70 @@
.gitignore
.npmignore
.openapi-generator-ignore
api.ts
base.ts
common.ts
configuration.ts
docs/APIErrorResponse.md
docs/Address.md
docs/ControllerApi.md
docs/ControllerInfo.md
docs/ControllerInfoApi.md
docs/CreateEnterpriseParams.md
docs/CreateGiteaCredentialsParams.md
docs/CreateGiteaEndpointParams.md
docs/CreateGithubCredentialsParams.md
docs/CreateGithubEndpointParams.md
docs/CreateOrgParams.md
docs/CreatePoolParams.md
docs/CreateRepoParams.md
docs/CreateScaleSetParams.md
docs/CredentialsApi.md
docs/EndpointsApi.md
docs/Enterprise.md
docs/EnterprisesApi.md
docs/EntityEvent.md
docs/FirstRunApi.md
docs/ForgeCredentials.md
docs/ForgeEndpoint.md
docs/ForgeEntity.md
docs/GithubApp.md
docs/GithubPAT.md
docs/GithubRateLimit.md
docs/HookInfo.md
docs/HooksApi.md
docs/InstallWebhookParams.md
docs/Instance.md
docs/InstancesApi.md
docs/JWTResponse.md
docs/Job.md
docs/JobsApi.md
docs/LoginApi.md
docs/MetricsTokenApi.md
docs/NewUserParams.md
docs/Organization.md
docs/OrganizationsApi.md
docs/PasswordLoginParams.md
docs/Pool.md
docs/PoolManagerStatus.md
docs/PoolsApi.md
docs/Provider.md
docs/ProvidersApi.md
docs/RepositoriesApi.md
docs/Repository.md
docs/RunnerPrefix.md
docs/ScaleSet.md
docs/ScalesetsApi.md
docs/StatusMessage.md
docs/Tag.md
docs/UpdateControllerParams.md
docs/UpdateEntityParams.md
docs/UpdateGiteaCredentialsParams.md
docs/UpdateGiteaEndpointParams.md
docs/UpdateGithubCredentialsParams.md
docs/UpdateGithubEndpointParams.md
docs/UpdatePoolParams.md
docs/UpdateScaleSetParams.md
docs/User.md
git_push.sh
index.ts

View file

@ -0,0 +1 @@
7.14.0

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,86 @@
/* tslint:disable */
/* eslint-disable */
/**
* Garm API.
* The Garm API generated using go-swagger.
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from './configuration';
// Some imports not used depending on template conditions
// @ts-ignore
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
export const BASE_PATH = "/api/v1".replace(/\/+$/, "");
/**
*
* @export
*/
export const COLLECTION_FORMATS = {
csv: ",",
ssv: " ",
tsv: "\t",
pipes: "|",
};
/**
*
* @export
* @interface RequestArgs
*/
export interface RequestArgs {
url: string;
options: RawAxiosRequestConfig;
}
/**
*
* @export
* @class BaseAPI
*/
export class BaseAPI {
protected configuration: Configuration | undefined;
constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) {
if (configuration) {
this.configuration = configuration;
this.basePath = configuration.basePath ?? basePath;
}
}
};
/**
*
* @export
* @class RequiredError
* @extends {Error}
*/
export class RequiredError extends Error {
constructor(public field: string, msg?: string) {
super(msg);
this.name = "RequiredError"
}
}
interface ServerMap {
[key: string]: {
url: string,
description: string,
}[];
}
/**
*
* @export
*/
export const operationServerMap: ServerMap = {
}

View file

@ -0,0 +1,150 @@
/* tslint:disable */
/* eslint-disable */
/**
* Garm API.
* The Garm API generated using go-swagger.
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "./configuration";
import type { RequestArgs } from "./base";
import type { AxiosInstance, AxiosResponse } from 'axios';
import { RequiredError } from "./base";
/**
*
* @export
*/
export const DUMMY_BASE_URL = 'https://example.com'
/**
*
* @throws {RequiredError}
* @export
*/
export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) {
if (paramValue === null || paramValue === undefined) {
throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`);
}
}
/**
*
* @export
*/
export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) {
if (configuration && configuration.apiKey) {
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
? await configuration.apiKey(keyParamName)
: await configuration.apiKey;
object[keyParamName] = localVarApiKeyValue;
}
}
/**
*
* @export
*/
export const setBasicAuthToObject = function (object: any, configuration?: Configuration) {
if (configuration && (configuration.username || configuration.password)) {
object["auth"] = { username: configuration.username, password: configuration.password };
}
}
/**
*
* @export
*/
export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const accessToken = typeof configuration.accessToken === 'function'
? await configuration.accessToken()
: await configuration.accessToken;
object["Authorization"] = "Bearer " + accessToken;
}
}
/**
*
* @export
*/
export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const localVarAccessTokenValue = typeof configuration.accessToken === 'function'
? await configuration.accessToken(name, scopes)
: await configuration.accessToken;
object["Authorization"] = "Bearer " + localVarAccessTokenValue;
}
}
function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void {
if (parameter == null) return;
if (typeof parameter === "object") {
if (Array.isArray(parameter)) {
(parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key));
}
else {
Object.keys(parameter).forEach(currentKey =>
setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`)
);
}
}
else {
if (urlSearchParams.has(key)) {
urlSearchParams.append(key, parameter);
}
else {
urlSearchParams.set(key, parameter);
}
}
}
/**
*
* @export
*/
export const setSearchParams = function (url: URL, ...objects: any[]) {
const searchParams = new URLSearchParams(url.search);
setFlattenedQueryParams(searchParams, objects);
url.search = searchParams.toString();
}
/**
*
* @export
*/
export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) {
const nonString = typeof value !== 'string';
const needsSerialization = nonString && configuration && configuration.isJsonMime
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
: nonString;
return needsSerialization
? JSON.stringify(value !== undefined ? value : {})
: (value || "");
}
/**
*
* @export
*/
export const toPathString = function (url: URL) {
return url.pathname + url.search + url.hash
}
/**
*
* @export
*/
export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) {
return <T = unknown, R = AxiosResponse<T>>(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url};
return axios.request<T, R>(axiosRequestArgs);
};
}

View file

@ -0,0 +1,115 @@
/* tslint:disable */
/* eslint-disable */
/**
* Garm API.
* The Garm API generated using go-swagger.
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface ConfigurationParameters {
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
username?: string;
password?: string;
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
basePath?: string;
serverIndex?: number;
baseOptions?: any;
formDataCtor?: new () => any;
}
export class Configuration {
/**
* parameter for apiKey security
* @param name security name
* @memberof Configuration
*/
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
username?: string;
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
password?: string;
/**
* parameter for oauth2 security
* @param name security name
* @param scopes oauth2 scope
* @memberof Configuration
*/
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
/**
* override base path
*
* @type {string}
* @memberof Configuration
*/
basePath?: string;
/**
* override server index
*
* @type {number}
* @memberof Configuration
*/
serverIndex?: number;
/**
* base options for axios calls
*
* @type {any}
* @memberof Configuration
*/
baseOptions?: any;
/**
* The FormData constructor that will be used to create multipart form data
* requests. You can inject this here so that execution environments that
* do not support the FormData class can still run the generated client.
*
* @type {new () => FormData}
*/
formDataCtor?: new () => any;
constructor(param: ConfigurationParameters = {}) {
this.apiKey = param.apiKey;
this.username = param.username;
this.password = param.password;
this.accessToken = param.accessToken;
this.basePath = param.basePath;
this.serverIndex = param.serverIndex;
this.baseOptions = {
...param.baseOptions,
headers: {
...param.baseOptions?.headers,
},
};
this.formDataCtor = param.formDataCtor;
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i');
return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json');
}
}

View file

@ -0,0 +1,18 @@
/* tslint:disable */
/* eslint-disable */
/**
* Garm API.
* The Garm API generated using go-swagger.
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export * from "./api";
export * from "./configuration";

View file

@ -0,0 +1,68 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher<{
click: void;
}>();
export let action: 'edit' | 'delete' | 'view' | 'add' = 'edit';
export let disabled: boolean = false;
export let title: string = '';
export let ariaLabel: string = '';
export let size: 'sm' | 'md' = 'md';
function handleClick() {
if (!disabled) {
dispatch('click');
}
}
$: baseClasses = 'transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50';
$: sizeClasses = {
'sm': 'p-1',
'md': 'p-2'
}[size];
$: actionClasses = {
'edit': 'text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 focus:ring-indigo-500',
'delete': 'text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 focus:ring-red-500',
'view': 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300 focus:ring-gray-500',
'add': 'text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 focus:ring-green-500'
}[action];
$: iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5';
$: allClasses = [baseClasses, sizeClasses, actionClasses].join(' ');
$: iconPaths = {
'edit': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />',
'delete': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />',
'view': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />',
'add': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />'
};
$: defaultTitles = {
'edit': 'Edit',
'delete': 'Delete',
'view': 'View',
'add': 'Add'
};
$: computedTitle = title || defaultTitles[action];
$: computedAriaLabel = ariaLabel || `${defaultTitles[action]} item`;
</script>
<button
type="button"
class={allClasses}
{disabled}
title={computedTitle}
aria-label={computedAriaLabel}
on:click={handleClick}
{...$$restProps}
>
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{@html iconPaths[action]}
</svg>
</button>

View file

@ -0,0 +1,48 @@
<script lang="ts">
export let variant: 'success' | 'error' | 'warning' | 'info' | 'gray' | 'blue' | 'green' | 'red' | 'yellow' | 'secondary' = 'gray';
export let size: 'sm' | 'md' = 'sm';
export let text: string;
export let ring: boolean = false;
const variants = {
success: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
error: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
warning: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
info: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
gray: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
blue: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
green: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
red: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
yellow: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
secondary: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'
};
const ringVariants = {
success: 'ring-green-600/20 dark:ring-green-400/30',
error: 'ring-red-600/20 dark:ring-red-400/30',
warning: 'ring-yellow-600/20 dark:ring-yellow-400/30',
info: 'ring-blue-600/20 dark:ring-blue-400/30',
gray: 'ring-gray-500/20 dark:ring-gray-400/30',
blue: 'ring-blue-600/20 dark:ring-blue-400/30',
green: 'ring-green-600/20 dark:ring-green-400/30',
red: 'ring-red-600/20 dark:ring-red-400/30',
yellow: 'ring-yellow-600/20 dark:ring-yellow-400/30',
secondary: 'ring-gray-500/20 dark:ring-gray-400/30'
};
const sizes = {
sm: 'px-2 py-1 text-xs',
md: 'px-2.5 py-0.5 text-xs'
};
$: classes = [
'inline-flex items-center rounded-full font-semibold',
variants[variant],
sizes[size],
ring ? `ring-1 ring-inset ${ringVariants[variant]}` : ''
].filter(Boolean).join(' ');
</script>
<span class={classes}>
{text}
</span>

View file

@ -0,0 +1,82 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher<{
click: void;
}>();
export let variant: 'primary' | 'secondary' | 'danger' | 'ghost' = 'primary';
export let size: 'sm' | 'md' | 'lg' = 'md';
export let disabled: boolean = false;
export let loading: boolean = false;
export let type: 'button' | 'submit' | 'reset' = 'button';
export let fullWidth: boolean = false;
export let icon: string | null = null;
export let iconPosition: 'left' | 'right' = 'left';
function handleClick() {
if (!disabled && !loading) {
dispatch('click');
}
}
$: baseClasses = 'inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 cursor-pointer disabled:cursor-not-allowed';
$: sizeClasses = {
'sm': 'px-3 py-2 text-sm',
'md': 'px-4 py-2 text-sm',
'lg': 'px-6 py-3 text-base'
}[size];
$: variantClasses = {
'primary': 'text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500 disabled:bg-gray-400 disabled:hover:bg-gray-400',
'secondary': 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 focus:ring-blue-500',
'danger': 'text-white bg-red-600 hover:bg-red-700 focus:ring-red-500 disabled:bg-gray-400 disabled:hover:bg-gray-400',
'ghost': 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 focus:ring-blue-500'
}[variant];
$: widthClasses = fullWidth ? 'w-full' : '';
$: opacityClasses = disabled ? 'opacity-50' : '';
$: allClasses = [baseClasses, sizeClasses, variantClasses, widthClasses, opacityClasses].filter(Boolean).join(' ');
$: iconClasses = {
'sm': 'h-4 w-4',
'md': 'h-5 w-5',
'lg': 'h-6 w-6'
}[size];
$: iconSpacing = {
'sm': iconPosition === 'left' ? '-ml-0.5 mr-2' : 'ml-2 -mr-0.5',
'md': iconPosition === 'left' ? '-ml-1 mr-2' : 'ml-2 -mr-1',
'lg': iconPosition === 'left' ? '-ml-1 mr-3' : 'ml-3 -mr-1'
}[size];
</script>
<button
{type}
{disabled}
class={allClasses}
on:click={handleClick}
{...$$restProps}
>
{#if loading}
<svg class="animate-spin {iconClasses} {iconPosition === 'left' ? '-ml-1 mr-2' : 'ml-2 -mr-1'}" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{:else if icon && iconPosition === 'left'}
<svg class="{iconClasses} {iconSpacing}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{@html icon}
</svg>
{/if}
<slot />
{#if icon && iconPosition === 'right' && !loading}
<svg class="{iconClasses} {iconSpacing}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{@html icon}
</svg>
{/if}
</button>

View file

@ -0,0 +1,403 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Button from './Button.svelte';
import Modal from './Modal.svelte';
import Tooltip from './Tooltip.svelte';
import { toastStore } from '$lib/stores/toast.js';
import { garmApi } from '$lib/api/client.js';
import type { ControllerInfo, UpdateControllerParams } from '$lib/api/generated/api.js';
export let controllerInfo: ControllerInfo;
const dispatch = createEventDispatcher<{
updated: ControllerInfo;
}>();
let showSettingsModal = false;
let saving = false;
// Edit form values
let metadataUrl = '';
let callbackUrl = '';
let webhookUrl = '';
let minimumJobAgeBackoff: number | null = null;
function openSettingsModal() {
// Pre-populate form with current values
metadataUrl = controllerInfo.metadata_url || '';
callbackUrl = controllerInfo.callback_url || '';
webhookUrl = controllerInfo.webhook_url || '';
minimumJobAgeBackoff = controllerInfo.minimum_job_age_backoff || null;
showSettingsModal = true;
}
async function saveSettings() {
try {
saving = true;
// Build update params - only include non-empty values
const updateParams: UpdateControllerParams = {};
if (metadataUrl.trim()) {
updateParams.metadata_url = metadataUrl.trim();
}
if (callbackUrl.trim()) {
updateParams.callback_url = callbackUrl.trim();
}
if (webhookUrl.trim()) {
updateParams.webhook_url = webhookUrl.trim();
}
if (minimumJobAgeBackoff !== null && minimumJobAgeBackoff >= 0) {
updateParams.minimum_job_age_backoff = minimumJobAgeBackoff;
}
// Update controller settings
const updatedInfo = await garmApi.updateController(updateParams);
toastStore.success(
'Settings Updated',
'Controller settings have been updated successfully.'
);
showSettingsModal = false;
// Update the controllerInfo and notify parent
controllerInfo = updatedInfo;
dispatch('updated', updatedInfo);
} catch (err) {
toastStore.error(
'Update Failed',
err instanceof Error ? err.message : 'Failed to update controller settings'
);
} finally {
saving = false;
}
}
function closeSettingsModal() {
showSettingsModal = false;
// Reset form values
metadataUrl = '';
callbackUrl = '';
webhookUrl = '';
minimumJobAgeBackoff = null;
}
// Form validation
$: isValidUrl = (url: string) => {
if (!url.trim()) return true; // Empty is allowed
try {
new URL(url);
return true;
} catch {
return false;
}
};
$: isFormValid =
isValidUrl(metadataUrl) &&
isValidUrl(callbackUrl) &&
isValidUrl(webhookUrl) &&
(minimumJobAgeBackoff === null || minimumJobAgeBackoff >= 0);
</script>
<div class="bg-white dark:bg-gray-800 shadow rounded-lg">
<div class="p-6">
<!-- Header with inline edit action -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center space-x-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900">
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Controller Information</h3>
<div class="mt-1">
<span class="text-sm text-gray-500 dark:text-gray-400">
v{controllerInfo.version?.replace(/^v/, '') || 'Unknown'}
</span>
</div>
</div>
</div>
<Button
variant="secondary"
size="sm"
on:click={openSettingsModal}
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
Settings
</Button>
</div>
<!-- Main content in clean grid layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left column - Identity & Config -->
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Identity</h4>
<div class="space-y-3">
<!-- Controller ID -->
<div>
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium">Controller ID</div>
<div class="mt-1 p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm font-mono text-gray-600 dark:text-gray-300 break-all min-h-[38px] flex items-center">
{controllerInfo.controller_id}
</div>
</div>
<!-- Hostname -->
<div>
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium">Hostname</div>
<div class="mt-1 p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm font-mono text-gray-600 dark:text-gray-300 break-all min-h-[38px] flex items-center">
{controllerInfo.hostname || 'Unknown'}
</div>
</div>
<!-- Job Age Backoff -->
<div>
<div class="flex items-center">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium">Job Age Backoff</div>
<div class="ml-2">
<Tooltip
title="Job Age Backoff"
content="Time in seconds GARM waits after receiving a new job before spinning up a runner. This delay allows existing idle runners to pick up jobs first, preventing unnecessary runner creation. Set to 0 for immediate response."
/>
</div>
</div>
<div class="mt-1 p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm font-mono text-gray-600 dark:text-gray-300 min-h-[38px] flex items-center">
{controllerInfo.minimum_job_age_backoff || 30}s
</div>
</div>
</div>
</div>
</div>
<!-- Right column - URLs & Integration -->
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Integration URLs</h4>
<div class="space-y-3">
<!-- Metadata URL -->
{#if controllerInfo.metadata_url}
<div>
<div class="flex items-center">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium">Metadata</div>
<div class="ml-2">
<Tooltip
title="Metadata URL"
content="URL where runners retrieve setup information and metadata. Runners must be able to connect to this URL during their initialization process. Usually accessible at /api/v1/metadata endpoint."
/>
</div>
</div>
<div class="mt-1 p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm font-mono text-gray-600 dark:text-gray-300 break-all min-h-[38px] flex items-center">
{controllerInfo.metadata_url}
</div>
</div>
{/if}
<!-- Callback URL -->
{#if controllerInfo.callback_url}
<div>
<div class="flex items-center">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium">Callback</div>
<div class="ml-2">
<Tooltip
title="Callback URL"
content="URL where runners send status updates and system information (OS version, runner agent ID, etc.) to the controller. Runners must be able to connect to this URL. Usually accessible at /api/v1/callbacks endpoint."
/>
</div>
</div>
<div class="mt-1 p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm font-mono text-gray-600 dark:text-gray-300 break-all min-h-[38px] flex items-center">
{controllerInfo.callback_url}
</div>
</div>
{/if}
<!-- Webhook URL -->
{#if controllerInfo.webhook_url}
<div>
<div class="flex items-center">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium">Webhook</div>
<div class="ml-2">
<Tooltip
title="Webhook Base URL"
content="Base URL for webhooks where GitHub sends job notifications. GARM needs to receive these webhooks to know when to create new runners for jobs. GitHub must be able to connect to this URL. Usually accessible at /webhooks endpoint."
/>
</div>
</div>
<div class="mt-1 p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm font-mono text-gray-600 dark:text-gray-300 break-all min-h-[38px] flex items-center">
{controllerInfo.webhook_url}
</div>
</div>
{/if}
<!-- If no URLs configured -->
{#if !controllerInfo.metadata_url && !controllerInfo.callback_url && !controllerInfo.webhook_url}
<div class="text-center py-4">
<svg class="mx-auto h-8 w-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No URLs configured</p>
<button
on:click={openSettingsModal}
class="mt-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 font-medium cursor-pointer"
>
Configure now
</button>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Controller webhook URL at the bottom if available -->
{#if controllerInfo.controller_webhook_url}
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-600">
<div class="flex items-center mb-2">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium">Controller Webhook URL</div>
<div class="ml-2">
<Tooltip
title="Controller Webhook URL"
content="Unique webhook URL for this GARM controller. This is the preferred URL to use in GitHub webhook settings as it's controller-specific and allows multiple GARM controllers to work with the same repository. Automatically combines the webhook base URL with the controller ID."
/>
</div>
</div>
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md border border-blue-200 dark:border-blue-800">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0 mt-0.5">
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
</div>
<div class="min-w-0 flex-1">
<code class="text-sm font-mono text-blue-800 dark:text-blue-300 break-all">
{controllerInfo.controller_webhook_url}
</code>
<p class="mt-1 text-xs text-blue-700 dark:text-blue-400">
Use this URL in your GitHub organization/repository webhook settings
</p>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
<!-- Settings Modal -->
{#if showSettingsModal}
<Modal on:close={closeSettingsModal}>
<div class="max-w-2xl w-full p-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Controller Settings</h3>
<form on:submit|preventDefault={saveSettings} class="space-y-4">
<!-- Metadata URL -->
<div>
<label for="metadataUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Metadata URL
</label>
<input
id="metadataUrl"
type="url"
bind:value={metadataUrl}
placeholder="https://garm.example.com/api/v1/metadata"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm"
class:border-red-300={!isValidUrl(metadataUrl)}
/>
{#if !isValidUrl(metadataUrl)}
<p class="mt-1 text-sm text-red-600">Please enter a valid URL</p>
{/if}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
URL where runners can fetch metadata and setup information
</p>
</div>
<!-- Callback URL -->
<div>
<label for="callbackUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Callback URL
</label>
<input
id="callbackUrl"
type="url"
bind:value={callbackUrl}
placeholder="https://garm.example.com/api/v1/callbacks"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm"
class:border-red-300={!isValidUrl(callbackUrl)}
/>
{#if !isValidUrl(callbackUrl)}
<p class="mt-1 text-sm text-red-600">Please enter a valid URL</p>
{/if}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
URL where runners send status updates and lifecycle events
</p>
</div>
<!-- Webhook URL -->
<div>
<label for="webhookUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Webhook Base URL
</label>
<input
id="webhookUrl"
type="url"
bind:value={webhookUrl}
placeholder="https://garm.example.com/webhooks"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm"
class:border-red-300={!isValidUrl(webhookUrl)}
/>
{#if !isValidUrl(webhookUrl)}
<p class="mt-1 text-sm text-red-600">Please enter a valid URL</p>
{/if}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
URL where GitHub/Gitea will send webhook events for job notifications
</p>
</div>
<!-- Minimum Job Age Backoff -->
<div>
<label for="minimumJobAgeBackoff" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Minimum Job Age Backoff (seconds)
</label>
<input
id="minimumJobAgeBackoff"
type="number"
min="0"
bind:value={minimumJobAgeBackoff}
placeholder="30"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Time to wait before spinning up a runner for a new job (0 = immediate)
</p>
</div>
<!-- Form Actions -->
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
disabled={saving}
on:click={closeSettingsModal}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={!isFormValid || saving}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</Modal>
{/if}

View file

@ -0,0 +1,213 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { garmApi } from '$lib/api/client.js';
import type { CreateEnterpriseParams, ForgeCredentials } from '$lib/api/generated/api.js';
import Modal from './Modal.svelte';
import { eagerCache, eagerCacheManager } from '$lib/stores/eager-cache.js';
const dispatch = createEventDispatcher<{
close: void;
submit: CreateEnterpriseParams;
}>();
let loading = false;
let error = '';
// Get credentials from eager cache
$: credentials = $eagerCache.credentials;
$: credentialsLoading = $eagerCache.loading.credentials;
// Form data
let formData: CreateEnterpriseParams = {
name: '',
credentials_name: '',
webhook_secret: '',
pool_balancer_type: 'roundrobin'
};
// Enterprises can't auto-generate webhook secrets since they can't install webhooks programmatically
// Only show GitHub credentials (enterprises are GitHub-only)
$: filteredCredentials = credentials.filter(cred => {
return cred.forge_type === 'github';
});
async function loadCredentialsIfNeeded() {
if (!$eagerCache.loaded.credentials && !$eagerCache.loading.credentials) {
try {
await eagerCacheManager.getCredentials();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load credentials';
}
}
}
// Check if all mandatory fields are filled
$: isFormValid = formData.name && formData.name.trim() !== '' &&
formData.credentials_name !== '' &&
(formData.webhook_secret && formData.webhook_secret.trim() !== '');
async function handleSubmit() {
if (!formData.name || !formData.name.trim()) {
error = 'Enterprise name is required';
return;
}
if (!formData.credentials_name) {
error = 'Please select credentials';
return;
}
try {
loading = true;
error = '';
const submitData: CreateEnterpriseParams = {
...formData
};
dispatch('submit', submitData);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create enterprise';
loading = false;
}
}
onMount(() => {
loadCredentialsIfNeeded();
});
</script>
<Modal on:close={() => dispatch('close')}>
<div class="max-w-2xl w-full p-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Create Enterprise</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Enterprises are only available for GitHub endpoints.
</p>
{#if error}
<div class="mb-4 rounded-md bg-red-50 dark:bg-red-900 p-4">
<p class="text-sm font-medium text-red-800 dark:text-red-200">{error}</p>
</div>
{/if}
{#if loading}
<div class="text-center py-4">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading...</p>
</div>
{:else}
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<!-- Enterprise Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Enterprise Name
</label>
<input
id="name"
type="text"
bind:value={formData.name}
required
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm"
placeholder="Enter enterprise name"
/>
</div>
<!-- Credentials -->
<div>
<label for="credentials" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
GitHub Credentials
</label>
<select
id="credentials"
bind:value={formData.credentials_name}
required
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm"
>
<option value="">Select GitHub credentials...</option>
{#each filteredCredentials as credential}
<option value={credential.name}>
{credential.name} ({credential.endpoint?.name || 'Unknown endpoint'})
</option>
{/each}
</select>
{#if credentialsLoading}
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400">
Loading credentials...
</p>
{:else if filteredCredentials.length === 0}
<p class="mt-1 text-xs text-red-600 dark:text-red-400">
No GitHub credentials found. Please create GitHub credentials first.
</p>
{/if}
</div>
<!-- Pool Balancer Type -->
<div>
<div class="flex items-center mb-1">
<label for="pool_balancer_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Pool Balancer Type
</label>
<div class="ml-2 relative group">
<svg class="w-4 h-4 text-gray-400 cursor-help" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-64 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
<div class="mb-2">
<strong>Round Robin:</strong> Cycles through pools in turn. Job 1 → Pool 1, Job 2 → Pool 2, etc.
</div>
<div>
<strong>Pack:</strong> Uses first available pool until full, then moves to next pool.
</div>
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
</div>
</div>
</div>
<select
id="pool_balancer_type"
bind:value={formData.pool_balancer_type}
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm"
>
<option value="roundrobin">Round Robin</option>
<option value="pack">Pack</option>
</select>
</div>
<!-- Webhook Secret -->
<div>
<label for="webhook_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Webhook Secret
</label>
<input
id="webhook_secret"
type="password"
bind:value={formData.webhook_secret}
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm"
placeholder="Enter webhook secret"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
You'll need to manually configure this secret in GitHub's enterprise webhook settings.
</p>
</div>
<!-- Actions -->
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
on:click={() => dispatch('close')}
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={loading || credentialsLoading || !isFormValid || filteredCredentials.length === 0}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{loading ? 'Creating...' : 'Create Enterprise'}
</button>
</div>
</form>
{/if}
</div>
</Modal>

View file

@ -0,0 +1,271 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { garmApi } from '$lib/api/client.js';
import type { CreateOrgParams, ForgeCredentials } from '$lib/api/generated/api.js';
import Modal from './Modal.svelte';
import { getForgeIcon } from '$lib/utils/common.js';
import ForgeTypeSelector from './ForgeTypeSelector.svelte';
import { eagerCache, eagerCacheManager } from '$lib/stores/eager-cache.js';
const dispatch = createEventDispatcher<{
close: void;
submit: CreateOrgParams & { install_webhook?: boolean; auto_generate_secret?: boolean };
}>();
let loading = false;
let error = '';
let selectedForgeType: 'github' | 'gitea' | '' = 'github';
// Get credentials from eager cache
$: credentials = $eagerCache.credentials;
$: credentialsLoading = $eagerCache.loading.credentials;
// Form data
let formData: CreateOrgParams = {
name: '',
credentials_name: '',
webhook_secret: '',
pool_balancer_type: 'roundrobin'
};
let installWebhook = true;
let generateWebhookSecret = true;
// Filtered credentials based on selected forge type
$: filteredCredentials = credentials.filter(cred => {
if (!selectedForgeType) return true;
return cred.forge_type === selectedForgeType;
});
async function loadCredentialsIfNeeded() {
if (!$eagerCache.loaded.credentials && !$eagerCache.loading.credentials) {
try {
await eagerCacheManager.getCredentials();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load credentials';
}
}
}
function handleForgeTypeSelect(event: CustomEvent<'github' | 'gitea'>) {
selectedForgeType = event.detail;
// Reset credential selection when forge type changes
formData.credentials_name = '';
}
function handleCredentialChange() {
// Auto-detect forge type when credential is selected
if (formData.credentials_name) {
const credential = credentials.find(c => c.name === formData.credentials_name);
if (credential && credential.forge_type) {
selectedForgeType = credential.forge_type as 'github' | 'gitea';
}
}
}
// Generate secure random webhook secret
function generateSecureWebhookSecret(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
// Auto-generate webhook secret when checkbox is checked
$: if (generateWebhookSecret) {
formData.webhook_secret = generateSecureWebhookSecret();
} else if (!generateWebhookSecret) {
// Clear the secret if user unchecks auto-generate
formData.webhook_secret = '';
}
// Check if all mandatory fields are filled
$: isFormValid = formData.name?.trim() !== '' &&
formData.credentials_name !== '' &&
(generateWebhookSecret || (formData.webhook_secret && formData.webhook_secret.trim() !== ''));
async function handleSubmit() {
if (!formData.name?.trim()) {
error = 'Organization name is required';
return;
}
if (!formData.credentials_name) {
error = 'Please select credentials';
return;
}
try {
loading = true;
error = '';
const submitData = {
...formData,
install_webhook: installWebhook,
auto_generate_secret: generateWebhookSecret
};
dispatch('submit', submitData);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create organization';
loading = false;
}
}
onMount(() => {
loadCredentialsIfNeeded();
});
</script>
<Modal on:close={() => dispatch('close')}>
<div class="max-w-2xl w-full p-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Create Organization</h3>
{#if error}
<div class="mb-4 rounded-md bg-red-50 dark:bg-red-900 p-4">
<p class="text-sm font-medium text-red-800 dark:text-red-200">{error}</p>
</div>
{/if}
{#if loading}
<div class="text-center py-4">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading...</p>
</div>
{:else}
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<!-- Forge Type Selection -->
<ForgeTypeSelector
bind:selectedForgeType
on:select={handleForgeTypeSelect}
/>
<!-- Organization Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Organization Name
</label>
<input
id="name"
type="text"
bind:value={formData.name}
required
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm"
placeholder="Enter organization name"
/>
</div>
<!-- Credentials -->
<div>
<label for="credentials" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Credentials
</label>
<select
id="credentials"
bind:value={formData.credentials_name}
on:change={handleCredentialChange}
required
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm"
>
<option value="">Select credentials...</option>
{#each filteredCredentials as credential}
<option value={credential.name}>
{credential.name} ({credential.endpoint?.name || 'Unknown endpoint'})
</option>
{/each}
</select>
</div>
<!-- Pool Balancer Type -->
<div>
<div class="flex items-center mb-1">
<label for="pool_balancer_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Pool Balancer Type
</label>
<div class="ml-2 relative group">
<svg class="w-4 h-4 text-gray-400 cursor-help" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-80 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
<div class="mb-2">
<strong>Round Robin:</strong> Cycles through pools in turn. Job 1 → Pool 1, Job 2 → Pool 2, etc.
</div>
<div>
<strong>Pack:</strong> Uses first available pool until full, then moves to next pool.
</div>
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
</div>
</div>
</div>
<select
id="pool_balancer_type"
bind:value={formData.pool_balancer_type}
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm"
>
<option value="roundrobin">Round Robin</option>
<option value="pack">Pack</option>
</select>
</div>
<!-- Webhook Configuration -->
<div>
<div class="flex items-center mb-3">
<input
id="install-webhook"
type="checkbox"
bind:checked={installWebhook}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 dark:border-gray-600 rounded"
/>
<label for="install-webhook" class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">
Install Webhook
</label>
</div>
<div class="space-y-3">
<div class="flex items-center">
<input
id="generate-webhook-secret"
type="checkbox"
bind:checked={generateWebhookSecret}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 dark:border-gray-600 rounded"
/>
<label for="generate-webhook-secret" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
Auto-generate webhook secret
</label>
</div>
{#if !generateWebhookSecret}
<input
type="password"
bind:value={formData.webhook_secret}
class="block w-full px-3 py-2 mt-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm"
placeholder="Enter webhook secret"
/>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">
Webhook secret will be automatically generated
</p>
{/if}
</div>
</div>
<!-- Actions -->
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
on:click={() => dispatch('close')}
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={loading || credentialsLoading || !isFormValid}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{loading ? 'Creating...' : 'Create Organization'}
</button>
</div>
</form>
{/if}
</div>
</Modal>

View file

@ -0,0 +1,541 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { garmApi } from '$lib/api/client.js';
import type {
CreatePoolParams,
Repository,
Organization,
Enterprise,
Provider
} from '$lib/api/generated/api.js';
import Modal from './Modal.svelte';
import JsonEditor from './JsonEditor.svelte';
const dispatch = createEventDispatcher<{
close: void;
submit: CreatePoolParams;
}>();
// Export props for pre-populating the modal
export let initialEntityType: 'repository' | 'organization' | 'enterprise' | '' = '';
export let initialEntityId: string = '';
let loading = false;
let error = '';
let entityLevel = initialEntityType;
let entities: (Repository | Organization | Enterprise)[] = [];
let providers: Provider[] = [];
let loadingEntities = false;
let loadingProviders = false;
// Form fields
let selectedEntityId = initialEntityId;
let selectedProvider = '';
let image = '';
let flavor = '';
let maxRunners: number | undefined = undefined;
let minIdleRunners: number | undefined = undefined;
let runnerBootstrapTimeout: number | undefined = undefined;
let priority: number = 100;
let runnerPrefix = 'garm';
let osType = 'linux';
let osArch = 'amd64';
let githubRunnerGroup = '';
let enabled = true;
let tags: string[] = [];
let newTag = '';
let extraSpecs = '{}';
async function loadProviders() {
try {
loadingProviders = true;
providers = await garmApi.listProviders();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load providers';
} finally {
loadingProviders = false;
}
}
async function loadEntities() {
if (!entityLevel) return;
try {
loadingEntities = true;
entities = [];
switch (entityLevel) {
case 'repository':
entities = await garmApi.listRepositories();
break;
case 'organization':
entities = await garmApi.listOrganizations();
break;
case 'enterprise':
entities = await garmApi.listEnterprises();
break;
}
} catch (err) {
error = err instanceof Error ? err.message : `Failed to load ${entityLevel}s`;
} finally {
loadingEntities = false;
}
}
function selectEntityLevel(level: 'repository' | 'organization' | 'enterprise') {
if (entityLevel === level) return;
entityLevel = level;
selectedEntityId = '';
loadEntities();
}
function addTag() {
if (newTag.trim() && !tags.includes(newTag.trim())) {
tags = [...tags, newTag.trim()];
newTag = '';
}
}
function removeTag(index: number) {
tags = tags.filter((_, i) => i !== index);
}
function handleTagKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
addTag();
}
}
async function handleSubmit() {
if (!entityLevel || !selectedEntityId || !selectedProvider || !image || !flavor) {
error = 'Please fill in all required fields';
return;
}
try {
loading = true;
error = '';
// Validate extra specs JSON
let parsedExtraSpecs: any = {};
if (extraSpecs.trim()) {
try {
parsedExtraSpecs = JSON.parse(extraSpecs);
} catch (e) {
throw new Error('Invalid JSON in extra specs');
}
}
const params: CreatePoolParams = {
provider_name: selectedProvider,
image,
flavor,
max_runners: maxRunners || 10,
min_idle_runners: minIdleRunners || 0,
runner_bootstrap_timeout: runnerBootstrapTimeout || 20,
priority,
runner_prefix: runnerPrefix,
os_type: osType as any,
os_arch: osArch as any,
'github-runner-group': githubRunnerGroup || undefined,
enabled,
tags,
extra_specs: extraSpecs.trim() ? parsedExtraSpecs : undefined
};
// Call the appropriate creation method based on entity level
let pool;
switch (entityLevel) {
case 'repository':
pool = await garmApi.createRepositoryPool(selectedEntityId, params);
break;
case 'organization':
pool = await garmApi.createOrganizationPool(selectedEntityId, params);
break;
case 'enterprise':
pool = await garmApi.createEnterprisePool(selectedEntityId, params);
break;
default:
throw new Error('Invalid entity level');
}
dispatch('submit', params);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create pool';
} finally {
loading = false;
}
}
onMount(() => {
loadProviders();
// If we have an initial entity type, load the entities for that type
if (initialEntityType) {
loadEntities();
}
});
</script>
<Modal on:close={() => dispatch('close')}>
<div class="max-w-6xl w-full max-h-[90vh] overflow-y-auto">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Create New Pool</h2>
</div>
<form on:submit|preventDefault={handleSubmit} class="p-6 space-y-6">
{#if error}
<div class="rounded-md bg-red-50 dark:bg-red-900 p-4">
<p class="text-sm font-medium text-red-800 dark:text-red-200">{error}</p>
</div>
{/if}
<!-- Entity Level Selection -->
<fieldset>
<legend class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Entity Level <span class="text-red-500">*</span>
</legend>
<div class="grid grid-cols-3 gap-4">
<button
type="button"
on:click={() => selectEntityLevel('repository')}
class="flex flex-col items-center justify-center p-4 border-2 rounded-lg transition-colors cursor-pointer {entityLevel === 'repository' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'}"
>
<svg class="w-8 h-8 mb-2 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z"/>
</svg>
<span class="text-sm font-medium text-gray-900 dark:text-white">Repository</span>
</button>
<button
type="button"
on:click={() => selectEntityLevel('organization')}
class="flex flex-col items-center justify-center p-4 border-2 rounded-lg transition-colors cursor-pointer {entityLevel === 'organization' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'}"
>
<svg class="w-8 h-8 mb-2 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
<span class="text-sm font-medium text-gray-900 dark:text-white">Organization</span>
</button>
<button
type="button"
on:click={() => selectEntityLevel('enterprise')}
class="flex flex-col items-center justify-center p-4 border-2 rounded-lg transition-colors cursor-pointer {entityLevel === 'enterprise' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'}"
>
<svg class="w-8 h-8 mb-2 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
<span class="text-sm font-medium text-gray-900 dark:text-white">Enterprise</span>
</button>
</div>
</fieldset>
{#if entityLevel}
<!-- Group 1: Entity & Provider Selection -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
Entity & Provider Configuration
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="entity" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{entityLevel.charAt(0).toUpperCase() + entityLevel.slice(1)} <span class="text-red-500">*</span>
</label>
{#if loadingEntities}
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-10 rounded"></div>
{:else}
<select
id="entity"
bind:value={selectedEntityId}
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">Select a {entityLevel}</option>
{#each entities as entity}
<option value={entity.id}>
{#if entityLevel === 'repository'}
{(entity as any).owner}/{entity.name} ({entity.endpoint?.name})
{:else}
{entity.name} ({entity.endpoint?.name})
{/if}
</option>
{/each}
</select>
{/if}
</div>
<div>
<label for="provider" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Provider <span class="text-red-500">*</span>
</label>
{#if loadingProviders}
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-10 rounded"></div>
{:else}
<select
id="provider"
bind:value={selectedProvider}
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">Select a provider</option>
{#each providers as provider}
<option value={provider.name}>{provider.name}</option>
{/each}
</select>
{/if}
</div>
</div>
</div>
<!-- Group 2: Image & OS Configuration -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
Image & OS Configuration
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="image" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Image <span class="text-red-500">*</span>
</label>
<input
id="image"
type="text"
bind:value={image}
required
placeholder="e.g., ubuntu:22.04"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label for="flavor" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Flavor <span class="text-red-500">*</span>
</label>
<input
id="flavor"
type="text"
bind:value={flavor}
required
placeholder="e.g., default"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label for="osType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
OS Type
</label>
<select
id="osType"
bind:value={osType}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="linux">Linux</option>
<option value="windows">Windows</option>
</select>
</div>
<div>
<label for="osArch" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Architecture
</label>
<select
id="osArch"
bind:value={osArch}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="amd64">AMD64</option>
<option value="arm64">ARM64</option>
</select>
</div>
</div>
</div>
<!-- Group 3: Runner Limits & Timing -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
Runner Limits & Timing
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="minIdleRunners" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Min Idle Runners
</label>
<input
id="minIdleRunners"
type="number"
bind:value={minIdleRunners}
min="0"
placeholder="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label for="maxRunners" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Max Runners
</label>
<input
id="maxRunners"
type="number"
bind:value={maxRunners}
min="1"
placeholder="10"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label for="bootstrapTimeout" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Bootstrap Timeout (min)
</label>
<input
id="bootstrapTimeout"
type="number"
bind:value={runnerBootstrapTimeout}
min="1"
placeholder="20"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
</div>
<!-- Group 4: Advanced Settings -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
Advanced Settings
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="runnerPrefix" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Runner Prefix
</label>
<input
id="runnerPrefix"
type="text"
bind:value={runnerPrefix}
placeholder="garm"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label for="priority" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Priority
</label>
<input
id="priority"
type="number"
bind:value={priority}
min="1"
placeholder="100"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label for="githubRunnerGroup" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub Runner Group (optional)
</label>
<input
id="githubRunnerGroup"
type="text"
bind:value={githubRunnerGroup}
placeholder="Default group"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
<!-- Tags -->
<div>
<label for="tag-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tags
</label>
<div class="space-y-2">
<div class="flex">
<input
id="tag-input"
type="text"
bind:value={newTag}
on:keydown={handleTagKeydown}
placeholder="Enter a tag"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-l-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<button
type="button"
on:click={addTag}
class="px-3 py-2 bg-blue-600 text-white rounded-r-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
>
Add
</button>
</div>
{#if tags.length > 0}
<div class="flex flex-wrap gap-2">
{#each tags as tag, index}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
{tag}
<button
type="button"
on:click={() => removeTag(index)}
aria-label={`Remove tag ${tag}`}
class="ml-1 h-4 w-4 rounded-full hover:bg-blue-200 dark:hover:bg-blue-800 flex items-center justify-center cursor-pointer"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>
{/each}
</div>
{/if}
</div>
</div>
<!-- Extra Specs -->
<fieldset>
<legend class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Extra Specs (JSON)
</legend>
<JsonEditor
bind:value={extraSpecs}
rows={4}
placeholder="{'{}'}"
/>
</fieldset>
<!-- Enabled Checkbox -->
<div class="flex items-center">
<input
id="enabled"
type="checkbox"
bind:checked={enabled}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 dark:border-gray-600 rounded"
/>
<label for="enabled" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Enable pool immediately
</label>
</div>
</div>
{/if}
<!-- Action Buttons -->
<div class="flex justify-end space-x-3 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
on:click={() => dispatch('close')}
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={loading || !entityLevel || !selectedEntityId || !selectedProvider || !image || !flavor}
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{#if loading}
<div class="flex items-center">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Creating...
</div>
{:else}
Create Pool
{/if}
</button>
</div>
</form>
</div>
</Modal>

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