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>
2
webapp/.env.development
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
VITE_GARM_API_URL=http://localhost:9997
|
||||
NODE_ENV=development
|
||||
8
webapp/.env.example
Normal 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
|
|
@ -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
|
|
@ -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.
|
||||
1
webapp/assets/_app/env.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const env={}
|
||||
1
webapp/assets/_app/immutable/assets/0.BPrCR_r7.css
Normal file
1
webapp/assets/_app/immutable/assets/_layout.BPrCR_r7.css
Normal file
1
webapp/assets/_app/immutable/chunks/5WA7h8uK.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/B3Pzt0F_.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/B7ITzBt8.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/BAg1iRPq.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/BE4wujub.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/BEkVdVE1.js
Normal 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};
|
||||
4
webapp/assets/_app/immutable/chunks/BGVHQGl-.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/BmGWMSQm.js
Normal file
1
webapp/assets/_app/immutable/chunks/C41YH50Q.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/C6k1Q4We.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/C89fcOde.js
Normal file
1
webapp/assets/_app/immutable/chunks/C9DJVOi1.js
Normal file
1
webapp/assets/_app/immutable/chunks/CCSWcuVN.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/CGpPw4EW.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/CLYUNKnN.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/CNMHKIIK.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/CO4LUyTP.js
Normal 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};
|
||||
3
webapp/assets/_app/immutable/chunks/CTf6mQoE.js
Normal file
1
webapp/assets/_app/immutable/chunks/CclkODgu.js
Normal file
7
webapp/assets/_app/immutable/chunks/CiE1LlKV.js
Normal file
1
webapp/assets/_app/immutable/chunks/CoIRRsD9.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
const s=globalThis.__sveltekit_13hoftk?.base??"/ui",t=globalThis.__sveltekit_13hoftk?.assets??s;export{t as a,s as b};
|
||||
1
webapp/assets/_app/immutable/chunks/CwqI2jFH.js
Normal file
1
webapp/assets/_app/immutable/chunks/D4Caz1gY.js
Normal 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};
|
||||
2
webapp/assets/_app/immutable/chunks/D8EpLgQ1.js
Normal file
1
webapp/assets/_app/immutable/chunks/DDhBTdDt.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/DQP15tlf.js
Normal file
4
webapp/assets/_app/immutable/chunks/DZblzgqm.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/Dbd6PPbz.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/DsnmJJEf.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
typeof window<"u"&&((window.__svelte??={}).v??=new Set).add("5");
|
||||
1
webapp/assets/_app/immutable/chunks/KQ2xQpA3.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/duD3WMbl.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/ow_oMtSd.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/qB7B8uiS.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/chunks/u94nIB4-.js
Normal file
1
webapp/assets/_app/immutable/chunks/wyaP0EDu.js
Normal file
2
webapp/assets/_app/immutable/entry/app.kAVAdeq9.js
Normal file
1
webapp/assets/_app/immutable/entry/start.CI0Cdear.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{l as o,a as r}from"../chunks/CTf6mQoE.js";export{o as load_css,r as start};
|
||||
13
webapp/assets/_app/immutable/nodes/0.DINiyk_8.js
Normal file
1
webapp/assets/_app/immutable/nodes/1.DcR4nNsi.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/nodes/10.LnrIJgIa.js
Normal 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};
|
||||
1
webapp/assets/_app/immutable/nodes/11.Bsn67lBa.js
Normal file
1
webapp/assets/_app/immutable/nodes/12.B-vC_cmu.js
Normal file
1
webapp/assets/_app/immutable/nodes/13.Br7HzjXP.js
Normal file
1
webapp/assets/_app/immutable/nodes/14.Cd0DOn96.js
Normal file
1
webapp/assets/_app/immutable/nodes/15.CkHQugXH.js
Normal file
1
webapp/assets/_app/immutable/nodes/16.B35VVkOd.js
Normal file
1
webapp/assets/_app/immutable/nodes/17.CCltcs-Z.js
Normal file
1
webapp/assets/_app/immutable/nodes/18.iVIhGVtu.js
Normal file
1
webapp/assets/_app/immutable/nodes/2.CiT4lj0D.js
Normal file
7
webapp/assets/_app/immutable/nodes/3.BSFz0YHn.js
Normal file
3
webapp/assets/_app/immutable/nodes/4.XnVoh6ca.js
Normal file
1
webapp/assets/_app/immutable/nodes/5.rvsSG-AQ.js
Normal file
1
webapp/assets/_app/immutable/nodes/6.CtGX0qgG.js
Normal file
1
webapp/assets/_app/immutable/nodes/7.0w3i9VHx.js
Normal file
1
webapp/assets/_app/immutable/nodes/8.BiZNKYxk.js
Normal file
1
webapp/assets/_app/immutable/nodes/9.DpSfMRgo.js
Normal file
1
webapp/assets/_app/version.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":"1755334486454"}
|
||||
83
webapp/assets/assets.go
Normal 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)
|
||||
}
|
||||
37
webapp/assets/assets/garm-dark.svg
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
36
webapp/assets/assets/garm-light.svg
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
1
webapp/assets/assets/gitea.svg
Normal 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 |
1
webapp/assets/assets/github-mark-white.svg
Normal 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 |
1
webapp/assets/assets/github-mark.svg
Normal 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 |
BIN
webapp/assets/favicon-dark.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
webapp/assets/favicon-light.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
105
webapp/assets/index.html
Normal 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>
|
||||
7
webapp/assets/openapitools.json
Normal 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
|
|
@ -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
43
webapp/package.json
Normal 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
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
18
webapp/src/app.css
Normal 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
|
|
@ -0,0 +1,10 @@
|
|||
declare global {
|
||||
namespace App {
|
||||
interface Error {}
|
||||
interface Locals {}
|
||||
interface PageData {}
|
||||
interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
78
webapp/src/app.html
Normal 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>
|
||||
77
webapp/src/lib/api/client.ts
Normal 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();
|
||||
596
webapp/src/lib/api/generated-client.ts
Normal 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();
|
||||
4
webapp/src/lib/api/generated/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
wwwroot/*.js
|
||||
node_modules
|
||||
typings
|
||||
dist
|
||||
1
webapp/src/lib/api/generated/.npmignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm
|
||||
23
webapp/src/lib/api/generated/.openapi-generator-ignore
Normal 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
|
||||
70
webapp/src/lib/api/generated/.openapi-generator/FILES
Normal 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
|
||||
1
webapp/src/lib/api/generated/.openapi-generator/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
7.14.0
|
||||
11684
webapp/src/lib/api/generated/api.ts
Normal file
86
webapp/src/lib/api/generated/base.ts
Normal 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 = {
|
||||
}
|
||||
150
webapp/src/lib/api/generated/common.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
115
webapp/src/lib/api/generated/configuration.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
18
webapp/src/lib/api/generated/index.ts
Normal 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";
|
||||
|
||||
68
webapp/src/lib/components/ActionButton.svelte
Normal 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>
|
||||
48
webapp/src/lib/components/Badge.svelte
Normal 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>
|
||||
82
webapp/src/lib/components/Button.svelte
Normal 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>
|
||||
403
webapp/src/lib/components/ControllerInfoCard.svelte
Normal 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}
|
||||
213
webapp/src/lib/components/CreateEnterpriseModal.svelte
Normal 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>
|
||||
271
webapp/src/lib/components/CreateOrganizationModal.svelte
Normal 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>
|
||||
541
webapp/src/lib/components/CreatePoolModal.svelte
Normal 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>
|
||||