9 Commits
Author SHA1 Message Date
sixsmith cd45bcf5c5 remove comment 2026-05-15 01:25:24 -04:00
sixsmith 8423cf5f69 working commit 2026-05-15 00:50:34 -04:00
sixsmith 62be5ddda7 test 2026-05-15 00:39:51 -04:00
sixsmith 3a88c8d878 dockerfile: update 2026-05-15 00:39:51 -04:00
sixsmith 25d01298e5 println -> log 2026-05-15 00:39:51 -04:00
sixsmith fd53e28143 api: fix routes 2026-05-15 00:39:51 -04:00
sixsmith c5e773a694 gofmt 2026-05-15 00:39:51 -04:00
sixsmith cd47863622 update docker 2026-05-15 00:39:51 -04:00
sixsmith 3775894a5b update docker 2026-05-15 00:39:51 -04:00
109 changed files with 621 additions and 17666 deletions
-17
View File
@@ -1,17 +0,0 @@
---
kind: pipeline
name: coopcloud.tech/abra-wizard
steps:
- name: publish image
image: plugins/docker
settings:
auto_tag: true
username: abra-wizard-bot
password:
from_secret: git_coopcloud_tech_token_abra_wizard
repo: git.coopcloud.tech/toolshed/coop-cloud-backend
registry: git.coopcloud.tech
when:
event:
- tag
-54
View File
@@ -1,54 +0,0 @@
name: Build And Publish nightly
on:
push:
branches:
- '*'
jobs:
build-latest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: 'recursive'
fetch-depth: 0
- name: Get short commit SHA
id: vars
run: |
calculatedSha=$(git rev-parse --short ${{ github.sha }})
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
platforms: linux/amd64
- name: Login to Self-Hosted Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: git.coopcloud.tech
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Build and Push nightly+HASH
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: ./
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:nightly-${{ env.COMMIT_SHORT_SHA }}
- name: Build and Push nightly
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: ./
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:nightly
-53
View File
@@ -1,53 +0,0 @@
name: Build And Publish Tag
on:
push:
tags:
- "[0-9]*.[0-9]*.[0-9]*"
workflow_dispatch:
inputs:
tag:
description: "Version tag (e.g., 0.1.0)"
required: true
jobs:
build-latest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'recursive'
fetch-depth: 0
- name: Get release tag
id: vars
run: |
if [ "${{ gitea.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ gitea.inputs.version }}"
else
TAG="${{ gitea.ref_name }}"
fi
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
with:
platforms: linux/amd64
- name: Login to Self-Hosted Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: git.coopcloud.tech
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: ./
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:${{ env.RELEASE_TAG }}
-5
View File
@@ -1,5 +0,0 @@
dot_abra/
ssh_config
id_*
app
coop-cloud-backend
+3
View File
@@ -0,0 +1,3 @@
[submodule "web"]
path = web
url = ssh://git@git.coopcloud.tech:2222/toolshed/coop-cloud-front.git
+16 -18
View File
@@ -1,32 +1,30 @@
FROM golang:1.25
RUN mkdir /backend
COPY --parents ["cli", "internal", "go.mod", "go.sum", "app.go", "/backend/"]
COPY --parents ["api", "cli", "internal", "go.mod", "go.sum", "app.go", "/backend/"]
RUN go build -C /backend -o gobackend
FROM git.coopcloud.tech/toolshed/abra:latest AS abra
FROM node:26-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
openssh-client \
ca-certificates \
git \
&& rm -rf /var/lib/apt/lists/*
FROM node
RUN mkdir /home/node/wizard
COPY --parents web /home/node/wizard
COPY --from=0 /backend/gobackend /home/node/wizard/gobackend
USER node
COPY --from=abra /abra /usr/local/bin/abra
COPY ./command.sh /command.sh
COPY --parents web /home/node/wizard
WORKDIR /home/node/wizard/web
USER root
RUN npm install .
USER node
RUN curl https://install.abra.coopcloud.tech | bash
ENV ABRA_BIN=/home/node/.local/bin/abra
RUN $ABRA_BIN recipe fetch -a
COPY ./start.sh /
COPY dot_abra /home/node/.abra/
COPY ssh_config /home/node/.ssh/config
COPY id_ed25519_* /home/node/.ssh/
USER root
RUN chown -R node /home/node/
USER node
EXPOSE 5173 3000
# ENV VITE_API_URL=http://localhost:3000/api
ENV VITE_MOCK_AUTH=false
CMD [ "/command.sh" ]
CMD [ "/start.sh" ]
+7 -33
View File
@@ -1,40 +1,14 @@
# coop-cloud-backend
A Go service that exposes RESTful API endpoints to manage Abra programmatically.
Integrates with https://git.coopcloud.tech/toolshed/coop-cloud-front.
Integrates with https://git.coopcloud.tech/BornDeleuze/coop-cloud-front.
## Starting the service with Docker
Pull the container:
```bash
docker pull git.coopcloud.tech/toolshed/coop-cloud-webui:nightly
```
Run the container:
```bash
docker run -u 1000:1000 -p 5173:5173 -p 3000:3000 -v $HOME/.ssh:/home/node/.ssh -v $HOME/.abra:/home/node/.abra coop-cloud-webui
```
Upstream container not working? You can build the container manually:
```bash
docker build -t coop-cloud-webui:latest .
```
## Getting started with development
- Clone the front end application `git clone https://git.coopcloud.tech/toolshed/coop-cloud-front.git`
- Checkout the `dev-nomock` branch `cd coop-cloud-front && git checkout dev-nomock`
## Getting started
- Edit the front-end application to turn off mock mode in `src/routes/Authenticated/App.tsx` and `src/routes/Authenticated/Apps.tsx`.
- Launch the front-end application `npm run dev`
- Start this Go app `go run .`
- Navigate to the React App (http://localhost:5173)
# Roadmap
## General Roadmap:
TODO
Repackaging
## Basic Abra Features
| Feature | Status | Follow-up |
| ----------- | ----------- | ----------- |
| More options for app deploy (esp chaos) | TODO | |
| View and pull in recipes from catalogue | Done | Refine Frontend |
| Deploy should show deployment status similar to Abra CLI | Done | Make more responsive and have page render more accurately |
| App screen should display services associated with app and their status | Done | |
| Easily show service logs | Done | More ways to process logs? |
| Modify App config | Done | |
| Manage updates and rollbacks | Done | |
| Manage secrets and Abra commands | In Progress | |
> [!WARNING]
> This is an extremely early prototype, only viewing apps is supported
> and may fail for your local machine
+439
View File
@@ -0,0 +1,439 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/upstream/stack"
appPkg "coopcloud.tech/abra/pkg/app"
configPkg "coopcloud.tech/abra/pkg/config"
deployPkg "coopcloud.tech/abra/pkg/deploy"
"net/http"
composetypes "github.com/docker/cli/cli/compose/types"
"coop-cloud-backend/internal"
)
type abraHandler struct {
mux *http.ServeMux
}
func newAbraHandler() *abraHandler {
h := &abraHandler{
mux: http.NewServeMux(),
}
h.mux.HandleFunc("/api/abra/apps", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
switch r.Method {
case http.MethodGet:
h.handleListApps(w, r)
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/abra/apps/{serverId}/{appId}/deploy", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
switch r.Method {
case http.MethodPost:
h.handleDeployApp(w, r, r.PathValue("appId"), r.PathValue("serverId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/abra/servers", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
switch r.Method {
case http.MethodGet:
h.handleListServers(w, r)
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
return h
}
func StartAPI() {
h := newAbraHandler()
log.Println("Server started on port 3000")
log.Fatal(http.ListenAndServe(":3000", h))
}
func (h *abraHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Pre-processing: logging
log.Printf("Incoming %s request: %s\n", r.Method, r.URL.Path)
// Delegate to internal mux
h.mux.ServeHTTP(w, r)
}
// func (h *appHandler) handleStartApp(w http.ResponseWriter, r *http.Request) {
// appName := r.PathValue("appName")
// w.Write([]byte("starting app: " + appName))
// }
func (h *abraHandler) handleDeployApp(w http.ResponseWriter, r *http.Request, appName string, serverId string) {
log.Printf("App Id: %s | Server Id: %s", appName, serverId)
app, err := GetApp(appName)
if err != nil {
log.Printf("Error getting app %s: %s\n", appName, err)
InternalServerErrorHandler(w, r)
return
}
cl, err := client.New(app.Server)
if err != nil {
log.Fatal(err)
InternalServerErrorHandler(w, r)
return
}
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
log.Fatal(err)
InternalServerErrorHandler(w, r)
return
}
if deployMeta.IsDeployed {
log.Fatalf("%s is already deployed", app.Name)
InternalServerErrorHandler(w, r)
return
}
// logic differs from CLI, we only want to take either
// 1. the chaos version
// 2. TODO: the version in the .env file
// 3. the latest version
// we never take: specific CLI verison (maybe will support this) or the deployed version
internal.Chaos = false
toDeployVersion, err := getDeployVersion(deployMeta, app)
if err != nil {
log.Fatal(err)
InternalServerErrorHandler(w, r)
return
}
if err := validateSecrets(cl, app); err != nil {
log.Fatal(err)
InternalServerErrorHandler(w, r)
return
}
if err := deployPkg.MergeAbraShEnv(app.Recipe, app.Env); err != nil {
log.Fatal(err)
InternalServerErrorHandler(w, r)
return
}
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
if err != nil {
log.Fatal(err)
InternalServerErrorHandler(w, r)
return
}
stackName := app.StackName()
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
log.Fatal(err)
InternalServerErrorHandler(w, r)
return
}
appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
if internal.Chaos {
appPkg.SetChaosVersionLabel(compose, stackName, toDeployVersion)
}
versionLabel := toDeployVersion
if internal.Chaos {
for _, service := range compose.Services {
if service.Name == "app" {
labelKey := fmt.Sprintf("coop-cloud.%s.version", stackName)
// NOTE(d1): keep non-chaos version labbeling when doing chaos ops
versionLabel = service.Deploy.Labels[labelKey]
}
}
}
appPkg.SetVersionLabel(compose, stackName, versionLabel)
newRecipeWithDeployVersion := fmt.Sprintf("%s:%s", app.Recipe.Name, toDeployVersion)
appPkg.ExposeAllEnv(stackName, compose, app.Env, newRecipeWithDeployVersion)
envVars, err := appPkg.CheckEnv(app)
if err != nil {
log.Fatal(err)
InternalServerErrorHandler(w, r)
return
}
// doesn't really get used at all right now
deployWarnMessages := []string{}
for _, envVar := range envVars {
if !envVar.Present {
deployWarnMessages = append(deployWarnMessages,
fmt.Sprintf("%s missing from %s.env", envVar.Name, app.Domain),
)
}
}
//skipping domain checks like crazy
//commented code is to show deploy overview before deploy
/*
deployedVersion := configPkg.MISSING_DEFAULT
if deployMeta.IsDeployed {
deployedVersion = deployMeta.Version
if deployMeta.IsChaos {
deployedVersion = deployMeta.ChaosVersion
}
}
ShowUnchanged := false
secretInfo, err := deployPkg.GatherSecretsForDeploy(cl, app, ShowUnchanged)
if err != nil {
log.Fatal(err)
InternalServerErrorHandler(w, r)
return
}
// Gather configs
configInfo, err := deployPkg.GatherConfigsForDeploy(cl, app, compose, app.Env, ShowUnchanged)
if err != nil {
log.Fatal(err)
InternalServerErrorHandler(w, r)
return
}
// Gather images
imageInfo, err := deployPkg.GatherImagesForDeploy(cl, app, compose, ShowUnchanged)
if err != nil {
log.Fatal(err)
InternalServerErrorHandler(w, r)
return
}*/
stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
if err != nil {
log.Fatal(err)
}
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
if err != nil {
log.Fatal(err)
}
f, err := app.Filters(true, false, serviceNames...)
if err != nil {
log.Fatal(err)
}
// in future allow user input?
DontWaitConverge := true
NoInput := true
if err := stack.RunDeploy(
cl,
deployOpts,
compose,
app.Name,
app.Server,
DontWaitConverge,
NoInput,
f,
); err != nil {
log.Fatal(err)
InternalServerErrorHandler(w, r)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *abraHandler) handleListApps(w http.ResponseWriter, r *http.Request) {
appNames, err := GetAppNames()
if err != nil {
log.Printf("Error getting app names: %s\n", err)
InternalServerErrorHandler(w, r)
return
}
// counts the number of apps in a server to initialize slices later
serverAppCount := make(map[string]int)
remoteApps := make([]appPkg.App, 0, len(appNames))
for _, appName := range appNames {
remoteApp, err := GetApp(appName)
serverAppCount[remoteApp.Server] += 1
if err != nil {
log.Printf("Error getting app %s: %s\n", appName, err)
InternalServerErrorHandler(w, r)
return
}
remoteApps = append(remoteApps, remoteApp)
}
appStatuses, err := GetAppStatuses(remoteApps)
if err != nil {
log.Printf("GetAppStatuses Falied\n")
log.Println("Error: ", err)
InternalServerErrorHandler(w, r)
return
}
abraAppResponse := ServerAppsResponse{}
for _, app := range remoteApps {
serverApps, ok := abraAppResponse[app.Server]
if !ok { // create the slice
// ever other field initializes to 0
serverApps = ServerApps{
Apps: make([]AbraApp, 0, serverAppCount[app.Server]),
}
}
appInfo, ok := appStatuses[app.StackName()]
if ok {
log.Printf("app %s is deployed\n", app.Name)
serverApps.Apps = append(serverApps.Apps, appTranspose(app, appInfo))
serverApps.AppCount += 1
// assume these are true rn idk
serverApps.VersionCount += 1
serverApps.LatestCount += 1
} else {
log.Printf("app %s is undeployed\n", app.Name)
serverApps.Apps = append(serverApps.Apps, appTransposeUndeployed(app))
serverApps.AppCount += 1
// assume these are true rn idk
serverApps.VersionCount += 1
serverApps.LatestCount += 1
}
abraAppResponse[app.Server] = serverApps
}
jsonBytes, err := json.Marshal(abraAppResponse)
if err != nil {
log.Printf("Error converting to json: %s\n", err)
InternalServerErrorHandler(w, r)
return
}
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}
func appTransposeUndeployed(app appPkg.App) AbraApp {
config, err := app.Recipe.GetComposeConfig(app.Env)
if err != nil {
log.Fatal(err)
}
version := GetLabel(config, app.StackName(), "version")
if version == "" {
version = "unknown"
}
return AbraApp{
AppName: app.Name,
Server: app.Server,
Recipe: app.Recipe.Name,
Domain: app.Domain,
Chaos: "false",
Status: "undeployed",
ChaosVersion: "unknown",
Version: version,
Upgrade: "latest",
}
}
func appTranspose(app appPkg.App, psInfo map[string]string) AbraApp {
return AbraApp{
AppName: app.Name,
Server: app.Server,
Recipe: app.Recipe.Name,
Domain: app.Domain,
Chaos: psInfo["chaos"],
Status: psInfo["status"],
ChaosVersion: getOrDefault(psInfo, "chaosVersion", "unknown"),
Version: psInfo["version"],
Upgrade: "latest",
}
}
func getOrDefault(m map[string]string, key, def string) string {
if v, ok := m[key]; ok {
return v
}
return def
}
func (h *abraHandler) handleListServers(w http.ResponseWriter, r *http.Request) {
servers, err := GetServers()
if err != nil {
InternalServerErrorHandler(w, r)
return
}
serverNames, err := ReadServerNames()
if err != nil {
InternalServerErrorHandler(w, r)
return
}
abraServers := make([]AbraServer, 0, len(servers))
for i := range len(servers) {
abraServers = append(abraServers,
AbraServer{
Name: serverNames[i],
Host: servers[i],
},
)
}
jsonBytes, err := json.Marshal(abraServers)
if err != nil {
InternalServerErrorHandler(w, r)
return
}
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}
func GetLabel(compose *composetypes.Config, stackName string, label string) string {
return appPkg.GetLabel(compose, stackName, label)
}
func GetAppStatuses(apps []appPkg.App) (map[string]map[string]string, error) {
return appPkg.GetAppStatuses(apps, true)
}
func GetApp(appName string) (appPkg.App, error) {
return appPkg.Get(appName)
}
func GetAppNames() ([]string, error) {
return appPkg.GetAppNames()
}
func GetAppServiceNames(appName string) ([]string, error) {
return appPkg.GetAppServiceNames(appName)
}
func GetServers() ([]string, error) {
return configPkg.GetServers()
}
func ReadServerNames() ([]string, error) {
return configPkg.ReadServerNames()
}
+13
View File
@@ -0,0 +1,13 @@
package api
import (
"net/http"
)
func InternalServerErrorHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("500 Internal Server Error"))
}
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("404 Not Found"))
}
+31
View File
@@ -0,0 +1,31 @@
package api
// Represents an App, follows the format of
// https://git.coopcloud.tech/BornDeleuze/coop-cloud-front
type AbraApp struct {
Server string `json:"server"`
Recipe string `json:"recipe`
AppName string `json:"appName"`
Domain string `json:"domain"`
Status string `json:"status"`
Chaos string `json:"chaos"`
ChaosVersion string `json:"chaosVersion"`
Version string `json:"version"`
Upgrade string `json:"upgrade"`
}
type AbraServer struct {
Name string `json:"name"`
Host string `json:"host"`
}
type ServerAppsResponse map[string]ServerApps
type ServerApps struct {
Apps []AbraApp `json:"apps"`
AppCount int `json:"appCount"`
VersionCount int `json:"versionCount"`
UnversionedCount int `json:"unversionedCount"`
LatestCount int `json:"latestCount"`
UpgradeCount int `json:"upgradeCount"`
}
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"fmt"
"errors"
"log"
"coopcloud.tech/abra/pkg/upstream/stack"
appPkg "coopcloud.tech/abra/pkg/app"
formatter "coopcloud.tech/abra/pkg/formatter"
secret "coopcloud.tech/abra/pkg/secret"
dockerClient "github.com/docker/docker/client"
"coop-cloud-backend/internal"
)
func getDeployVersion(deployMeta stack.DeployMeta, app appPkg.App) (string, error) {
if internal.Chaos {
v, err := app.Recipe.ChaosVersion()
if err != nil {
return "", err
}
log.Printf("version: taking chaos version: %s", v)
return v, nil
}
v, err := getLatestVersionOrCommit(app)
log.Printf("version: taking new recipe version: %s", v)
if err != nil {
return "", err
}
return v, nil
}
func getLatestVersionOrCommit(app appPkg.App) (string, error) {
recipeVersions, warnings, err := app.Recipe.GetRecipeVersions()
if err != nil {
return "", err
}
for _, warning := range warnings {
log.Printf(warning)
}
if len(recipeVersions) > 0 && !internal.Chaos {
latest := recipeVersions[len(recipeVersions)-1]
for tag := range latest {
log.Printf("selected latest recipe version: %s (from %d available versions)", tag, len(recipeVersions))
return tag, nil
}
}
head, err := app.Recipe.Head()
if err != nil {
return "", err
}
return formatter.SmallSHA(head.String()), nil
}
func validateSecrets(cl *dockerClient.Client, app appPkg.App) error {
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
if err != nil {
return err
}
secretsConfig, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName())
if err != nil {
return err
}
secStats, err := secret.PollSecretsStatus(cl, app)
if err != nil {
return err
}
for _, secStat := range secStats {
if !secStat.CreatedOnRemote {
secretConfig := secretsConfig[secStat.LocalName]
if secretConfig.SkipGenerate {
return errors.New(fmt.Sprintf("secret not inserted (#generate=false): %s", secStat.LocalName))
}
return errors.New(fmt.Sprintf("secret not generated: %s", secStat.LocalName))
}
}
return nil
}
+6 -6
View File
@@ -17,7 +17,7 @@ import (
// "github.com/spf13/cobra"
// "coopcloud.tech/abra/pkg/log"
"coop-cloud-backend/cli"
"coop-cloud-backend/api"
)
// getEnv reads env variables from docker services.
@@ -49,7 +49,7 @@ import (
// return envMap, nil
// }
func printApp(app appPkg.App) error {
fmt.Printf("Name: %s | Domain: %s | Server: %s | Path: %s",
log.Printf("Name: %s | Domain: %s | Server: %s | Path: %s",
app.Name, app.Domain, app.Server, app.Path)
return nil
}
@@ -64,13 +64,13 @@ func connectToContainer(app appPkg.App, serviceName string) {
filters := filters.NewArgs()
filters.Add("name", stackAndServiceName)
fmt.Printf("HI?")
log.Printf("HI?")
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, false)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Container ID: %s", targetContainer.ID)
log.Printf("Container ID: %s", targetContainer.ID)
}
func main() {
appNames, err := appPkg.GetAppNames()
@@ -79,8 +79,8 @@ func main() {
}
for _, appName := range appNames {
val := fmt.Sprintf("app , %v", appName)
fmt.Println(val)
log.Println(val)
}
cli.StartAPI()
api.StartAPI()
}
-268
View File
@@ -1,268 +0,0 @@
package cli
import (
"coop-cloud-backend/cli/external"
"fmt"
"log"
"net/http"
"os/exec"
"github.com/stretchr/testify/mock"
)
// // Upgrader is used to upgrade HTTP connections to WebSocket connections.
// var upgrader = websocket.Upgrader{
// CheckOrigin: func(r *http.Request) bool {
// return true
// },
// }
// func wsHandler(w http.ResponseWriter, r *http.Request) {
// // Upgrade the HTTP connection to a WebSocket connection
// conn, err := upgrader.Upgrade(w, r, nil)
// if err != nil {
// fmt.Println("Error upgrading:", err)
// return
// }
// defer conn.Close()
// // Listen for incoming messages
// for {
// // Read message from the client
// _, message, err := conn.ReadMessage()
// if err != nil {
// fmt.Println("Error reading message:", err)
// break
// }
// fmt.Printf("Received: %s\\n", message)
// // Echo the message back to the client
// if err := conn.WriteMessage(websocket.TextMessage, message); err != nil {
// fmt.Println("Error writing message:", err)
// break
// }
// }
// }
type MockCommandRunner struct {
mock.Mock
}
func (m *MockCommandRunner) Run(name string, args ...string) ([]byte, error) {
callArgs := []interface{}{name}
for _, arg := range args {
callArgs = append(callArgs, arg)
}
resp := m.Called(callArgs...)
return resp.Get(0).([]byte), resp.Error(1)
}
// abstracted out interface into this runner so i can write tests
type CommandRunner interface {
Run(name string, args ...string) ([]byte, error)
}
type ExecRunner struct{}
func (ExecRunner) Run(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).CombinedOutput()
}
type abraHandler struct {
mux *http.ServeMux
cmd CommandRunner
secret SecretUtilsWrapper
ap external.AppPackage
sp external.ServerPackage
rp external.RecipePackage
}
func newAbraHandler() *abraHandler {
h := &abraHandler{
mux: http.NewServeMux(),
cmd: ExecRunner{},
secret: &SecretUtils{},
ap: &external.AbraAppPackage{},
sp: &external.AbraServerPackage{},
rp: &external.AbraRecipePackage{},
}
h.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Printf("API Path not found")
http.Error(w, "API Path not found", http.StatusMethodNotAllowed)
})
h.mux.HandleFunc("/api/apps", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleListApps(w, r)
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/deploy", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
h.handleDeployApp(w, r, r.PathValue("appId"), r.URL.Query().Get("chaos") == "true")
case http.MethodGet:
h.getDeployLogs(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/upgrade", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
h.handleUpgradeApp(w, r, r.PathValue("appId"), r.URL.Query().Get("force") == "true")
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/rollback", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
h.handleRollbackApp(w, r, r.PathValue("appId"), r.URL.Query().Get("force") == "true")
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/backup/create", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
h.handleBackupCreateApp(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/stop", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
h.handleUndeployApp(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/remove", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
h.handleRemoveApp(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/servers", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleListServers(w, r)
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/catalogue", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleListCatalogue(w, r)
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/new", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
h.handleNewApp(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/config", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
h.handlePostConfig(w, r, r.PathValue("appId"))
case http.MethodGet:
h.handleGetConfig(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/backup/list", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleBackupListApp(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/backup/restore", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
h.handleBackupRestoreApp(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/backup/download", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleBackupDownloadSnapshot(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/secret", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
h.handleInsertAppSecret(w, r, r.PathValue("appId"))
case http.MethodGet:
h.handleGetAppSecrets(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/services", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleGetAppServices(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/{serviceId}/logs", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleGetLogs(w, r, r.PathValue("appId"), r.PathValue("serviceId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/versions", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleListVersions(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
return h
}
func StartAPI() {
/*http.HandleFunc("/ws", wsHandler)
fmt.Println("WebSocket server started on :3001")
err := http.ListenAndServe(":3001", nil)
if err != nil {
fmt.Println("Error starting server:", err)
}*/
h := newAbraHandler()
fmt.Println("Server started on port 3000")
http.ListenAndServe(":3000", h)
}
func (h *abraHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Pre-processing: logging
log.Printf("Incoming %s request: %s\n", r.Method, r.URL.Path)
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// Delegate to internal mux
h.mux.ServeHTTP(w, r)
}
-20
View File
@@ -1,20 +0,0 @@
package cli
import (
"context"
"errors"
"io"
"log"
"coopcloud.tech/abra/pkg/i18n"
dockerClient "github.com/docker/docker/client"
)
func CopyFromContainer(cl *dockerClient.Client, containerID, srcPath string) (io.ReadCloser, int64, error) {
log.Printf("%v %v", containerID, srcPath)
content, stat, err := cl.CopyFromContainer(context.Background(), containerID, srcPath)
if err != nil {
return nil, 0, errors.New(i18n.G("copy: %s", err))
}
return content, stat.Size, nil
}
-284
View File
@@ -1,284 +0,0 @@
package cli
import (
"archive/tar"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"path/filepath"
"strconv"
"strings"
// used for replacing abra code
"coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
containerPkg "coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/service"
"github.com/docker/docker/api/types"
containertypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stdcopy"
)
func (h *abraHandler) pingBackupBot(w http.ResponseWriter, r *http.Request, server string) {
cl, err := client.New(server)
if err != nil {
InternalServerError(w, err)
}
_, err = RetrieveBackupBotContainer(cl)
if err != nil {
InternalServerError(w, err)
}
w.WriteHeader(http.StatusOK)
}
func (h *abraHandler) handleBackupCreateApp(w http.ResponseWriter, r *http.Request, appName string) {
log.Printf("Handling App Backup!")
args := []string{}
_, err := handleBackupCommand(appName, args, "create")
if err != nil {
InternalServerError(w, err)
}
log.Printf("Finishing app backup!")
w.WriteHeader(http.StatusOK)
}
func (h *abraHandler) handleBackupRestoreApp(w http.ResponseWriter, r *http.Request, appName string) {
log.Printf("Handling Backup Restore!")
args := []string{}
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
body := struct {
Snapshot *string `json:"snapshot"`
}{}
err := d.Decode(&body)
if err != nil {
log.Printf("error: %v", err)
BadRequest(w, err)
return
}
if body.Snapshot != nil {
args = append(args, fmt.Sprintf("SNAPSHOT=%s", *body.Snapshot))
}
args = append(args, fmt.Sprintf("NONINTERACTIVE=%v", true))
_, err = handleBackupCommand(appName, args, "restore")
if err != nil {
InternalServerError(w, err)
}
log.Printf("Finishing app restore!")
w.WriteHeader(http.StatusOK)
}
func (h *abraHandler) handleBackupListApp(w http.ResponseWriter, r *http.Request, appName string) {
log.Printf("Handling App List Backup!")
args := []string{}
output, err := handleBackupCommand(appName, args, "snapshots")
if err != nil {
InternalServerError(w, err)
}
snapshots := strings.Split(output, "\n")
resp := []AppSnapshot{}
for _, snapshot := range snapshots {
items := strings.Split(snapshot, " ")
if len(items) < 3 {
break
}
resp = append(resp, AppSnapshot{
CreatedAt: items[0],
Snapshot: items[1],
Version: items[2],
})
}
jsonBytes, err := json.Marshal(resp)
if err != nil {
InternalServerError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}
func (h *abraHandler) handleBackupDownloadSnapshot(w http.ResponseWriter, r *http.Request, appName string) {
log.Printf("Handling Snapshot Download!")
app, err := app.Get(appName)
if err != nil {
InternalServerError(w, err)
}
cl, err := client.New(app.Server)
if err != nil {
InternalServerError(w, err)
}
targetContainer, err := RetrieveBackupBotContainer(cl)
if err != nil {
InternalServerError(w, err)
}
execEnv := []string{
fmt.Sprintf("SERVICE=%s", app.Domain),
"MACHINE_LOGS=true",
}
// TODO: add args
_, err = RunBackupCmdRemote(cl, "download", targetContainer.ID, execEnv)
if err != nil {
InternalServerError(w, err)
}
remoteBackupDir := "/tmp/backup.tar.gz"
content, size, err := CopyFromContainer(cl, targetContainer.ID, remoteBackupDir)
if err != nil {
InternalServerError(w, fmt.Errorf("error copying from container: %v", err))
}
defer content.Close()
w.Header().Set("Content-type", "application/x-tar")
tr := tar.NewReader(content)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
InternalServerError(w, err)
}
if filepath.Base(header.Name) == "backup.tar.gz" {
w.Header().Set(
"Content-Disposition",
`attachment; filename="backup.tar.gz"`,
)
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set(
"Content-Length",
strconv.FormatInt(size, 10),
)
if _, err := io.Copy(w, tr); err != nil {
InternalServerError(w, err)
}
return
}
}
}
func handleBackupCommand(appName string, args []string, action string) (string, error) {
app, err := app.Get(appName)
if err != nil {
return "", err
}
cl, err := client.New(app.Server)
if err != nil {
return "", err
}
targetContainer, err := RetrieveBackupBotContainer(cl)
if err != nil {
return "", err
}
execEnv := []string{
fmt.Sprintf("SERVICE=%s", app.Domain),
"MACHINE_LOGS=true",
}
for _, arg := range args {
execEnv = append(execEnv, arg)
}
output, err := RunBackupCmdRemote(cl, action, targetContainer.ID, execEnv)
if err != nil {
return "", err
}
return output, nil
}
// copied from https://git.coopcloud.tech/toolshed/abra/src/branch/main/cli/internal/backup.go
// tty is set to false
func RunBackupCmdRemote(
cl *dockerClient.Client,
backupCmd string,
containerID string,
execEnv []string) (string, error) {
execBackupListOpts := containertypes.ExecOptions{
AttachStderr: true,
AttachStdout: true,
Cmd: []string{"/usr/bin/backup", "--", backupCmd},
Env: execEnv,
Tty: false,
}
stdout, err := Exec(context.Background(), cl, containerID, &execBackupListOpts)
log.Printf("stdout: %v | err: %v", stdout, err)
if err != nil {
return "", err
}
return stdout, nil
}
func RetrieveBackupBotContainer(cl *dockerClient.Client) (types.Container, error) {
ctx := context.Background()
chosenService, err := service.GetServiceByLabel(ctx, cl, config.BackupbotLabel, true)
if err != nil {
return types.Container{}, errors.New(i18n.G("no backupbot discovered, is it deployed?"))
}
filters := filters.NewArgs()
filters.Add("name", chosenService.Spec.Name)
targetContainer, err := containerPkg.GetContainer(
ctx,
cl,
filters,
true,
)
if err != nil {
return types.Container{}, err
}
return targetContainer, nil
}
func Exec(ctx context.Context, cli *dockerClient.Client, containerID string, execOptions *container.ExecOptions) (string, error) {
execResp, err := cli.ContainerExecCreate(ctx, containerID, *execOptions)
if err != nil {
return "", err
}
resp, err := cli.ContainerExecAttach(ctx, execResp.ID, container.ExecStartOptions{
Tty: false,
})
if err != nil {
return "", err
}
defer resp.Close()
var stdout, stderr bytes.Buffer
_, err = stdcopy.StdCopy(&stdout, &stderr, resp.Reader)
if err != nil {
return "", err
}
inspect, err := cli.ContainerExecInspect(ctx, execResp.ID)
if err != nil {
return "", err
}
if inspect.ExitCode != 0 {
return stdout.String(),
fmt.Errorf("command exited with status %d and error %s", inspect.ExitCode, stderr.String())
}
return stdout.String(), nil
}
-28
View File
@@ -1,28 +0,0 @@
package cli
import (
"net/http"
"slices"
"log"
"maps"
"encoding/json"
"coopcloud.tech/abra/pkg/recipe"
)
func (h *abraHandler) handleListCatalogue(w http.ResponseWriter, r *http.Request) {
offline := false
catl, err := recipe.ReadRecipeCatalogue(offline)
if err != nil {
log.Fatal(err)
}
vals := slices.Collect(maps.Values(catl))
jsonBytes, err := json.Marshal(vals)
if err != nil {
InternalServerError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}
-74
View File
@@ -1,74 +0,0 @@
package cli
import (
"log"
"io/ioutil"
"os"
"net/http"
"encoding/json"
appPkg "coopcloud.tech/abra/pkg/app"
)
type FileResponse struct {
Content string `json:"content"`
}
func (h *abraHandler) handleGetConfig(w http.ResponseWriter, r *http.Request, appName string) {
files, err := appPkg.LoadAppFiles("")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
appFile, exists := files[appName]
if !exists {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("path: %s | server: %s", appFile.Path, appFile.Server)
log.Printf("Ending...")
// TODO: sanitize
file, err := ioutil.ReadFile(appFile.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := FileResponse{Content: string(file)}
jsonBytes, err := json.Marshal(resp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}
func (h *abraHandler) handlePostConfig(w http.ResponseWriter, r *http.Request, appName string) {
files, err := appPkg.LoadAppFiles("")
if err != nil {
InternalServerError(w, err)
return
}
appFile, exists := files[appName]
if !exists {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields() // catch unwanted fields
var config FileResponse
err = d.Decode(&config)
if err != nil {
BadRequest(w, err)
return
}
err = os.WriteFile(appFile.Path, []byte(config.Content), 0644)
if err != nil {
http.Error(w, "Failed to write file", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
-139
View File
@@ -1,139 +0,0 @@
package cli
import (
"context"
"coop-cloud-backend/cli/status"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"coopcloud.tech/abra/pkg/client"
//"coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/abra/pkg/ui"
"coopcloud.tech/abra/pkg/upstream/convert"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/swarm"
dclient "github.com/docker/docker/client"
appPkg "coopcloud.tech/abra/pkg/app"
//"github.com/gorilla/websocket"
)
func (h *abraHandler) handleDeployApp(w http.ResponseWriter, r *http.Request, appName string, chaos bool) {
log.Printf("Handling App Deploy!")
args := []string{"app", "deploy", appName, "-n", "-c"}
if chaos {
args = append(args, "-C")
}
output, err := h.cmd.Run("abra", args...)
if err != nil {
InternalServerError(w, errors.New(fmt.Sprintf("Error: %s", string(output))))
return
}
log.Printf("Finishing app deploy!")
w.WriteHeader(http.StatusOK)
}
func (h *abraHandler) handleUndeployApp(w http.ResponseWriter, r *http.Request, appName string) {
output, err := h.cmd.Run("abra", "app", "undeploy", appName, "-n")
if err != nil {
InternalServerError(w, fmt.Errorf("Error: %s", string(output)))
return
}
w.WriteHeader(http.StatusOK)
}
func (h *abraHandler) getDeployLogs(w http.ResponseWriter, r *http.Request, appName string) {
log.Printf("Get deploy logs!")
app, err := appPkg.Get(appName)
if err != nil {
InternalServerError(w, err)
return
}
// Implement my own WaitOnServices in order to wrap ui.Model in order to emit JSON updates
// over Websocket connection
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
if err != nil {
InternalServerError(w, err)
}
f, err := app.Filters(true, false, serviceNames...)
if err != nil {
InternalServerError(w, err)
}
// STEP 1: collect information about app being deployed
cl, err := client.New(app.Server)
if err != nil {
InternalServerError(w, err)
return
}
var serviceIDs []ui.ServiceMeta
stackName := app.StackName()
namespace := convert.NewNamespace(stackName)
log.Printf("stack name: %s | namespace: %s", stackName, namespace.Name())
existingServices, err := GetStackServices(context.Background(), cl, namespace.Name())
if err != nil {
InternalServerError(w, err)
return
}
for _, service := range existingServices {
log.Printf("existingServices contains: %s", service.Spec.Name)
serviceIDs = append(serviceIDs, ui.ServiceMeta{
Name: service.Spec.Name,
ID: service.ID,
})
}
ctx := r.Context()
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
log.Printf("Waiting on service...")
stream := make(chan status.StreamEvent, 50)
go status.WaitOnServices(ctx, cl, serviceIDs, f, stream)
for {
select {
case <-ctx.Done():
log.Printf("deploy cancelled or done")
return
case msg, ok := <-stream:
if !ok {
return
}
test, err := json.MarshalIndent(msg, "", " ")
fmt.Printf(string(test), err)
b, err := json.Marshal(msg)
if err != nil {
OnError(err)
continue
}
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
}
}
func getStackFilter(namespace string) filters.Args {
filter := filters.NewArgs()
filter.Add("label", convert.LabelNamespace+"="+namespace)
return filter
}
func getStackServiceFilter(namespace string) filters.Args {
return getStackFilter(namespace)
}
func GetStackServices(ctx context.Context, dockerclient dclient.APIClient, namespace string) ([]swarm.Service, error) {
return dockerclient.ServiceList(ctx, types.ServiceListOptions{Filters: getStackServiceFilter(namespace)})
}
-20
View File
@@ -1,20 +0,0 @@
package cli
import (
"net/http"
"log"
)
func OnError(err error) {
// handle errors that continue
log.Printf("Error: %v", err)
}
func InternalServerError(w http.ResponseWriter, err error) {
log.Printf("Error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
func NotFound(w http.ResponseWriter, err error) {
http.Error(w, "not found", http.StatusNotFound)
}
func BadRequest(w http.ResponseWriter, err error) {
http.Error(w, "bad request", http.StatusBadRequest)
}
-64
View File
@@ -1,64 +0,0 @@
package external
import (
appPkg "coopcloud.tech/abra/pkg/app"
"github.com/stretchr/testify/mock"
)
type AppPackage interface {
GetApps(recipeFilter string, servers ...string) ([]appPkg.App, error)
GetAppStatuses(apps []appPkg.App, MachineReadable bool) (map[string]map[string]string, error)
StackName(app appPkg.App) string
}
type AbraAppPackage struct{}
func (a *AbraAppPackage) GetApps(recipeFilter string, servers ...string) ([]appPkg.App, error) {
appFiles, err := appPkg.LoadAppFiles(servers...)
if err != nil {
return nil, err
}
apps, err := appPkg.GetApps(appFiles, recipeFilter)
if err != nil {
return nil, err
}
return apps, nil
}
func (a *AbraAppPackage) GetAppStatuses(apps []appPkg.App, MachineReadable bool) (map[string]map[string]string, error) {
return appPkg.GetAppStatuses(apps, MachineReadable)
}
func (a *AbraAppPackage) StackName(app appPkg.App) string {
return app.StackName()
}
type MockAppPackage struct {
mock.Mock
}
func (a *MockAppPackage) GetApps(recipeFilter string, servers ...string) ([]appPkg.App, error) {
callArgs := []interface{}{recipeFilter}
for _, arg := range servers {
callArgs = append(callArgs, arg)
}
args := a.Called(callArgs...)
return args.Get(0).([]appPkg.App), args.Error(1)
}
func (a *MockAppPackage) GetAppStatuses(apps []appPkg.App, MachineReadable bool) (map[string]map[string]string, error) {
callArgs := []interface{}{}
for _, arg := range apps {
callArgs = append(callArgs, arg)
}
callArgs = append(callArgs, MachineReadable)
args := a.Called(callArgs...)
return args.Get(0).(map[string]map[string]string), args.Error(1)
}
func (a *MockAppPackage) StackName(app appPkg.App) string {
callArgs := []interface{}{app}
args := a.Called(callArgs...)
return args.Get(0).(string)
}
-55
View File
@@ -1,55 +0,0 @@
package external
import (
"context"
"coopcloud.tech/abra/pkg/upstream/convert"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/swarm"
dockerClient "github.com/docker/docker/client"
"github.com/stretchr/testify/mock"
)
type DockerClient interface {
GetStackServices(ctx context.Context, dockerclient dockerClient.APIClient, namespace string) ([]swarm.Service, error)
}
type AbraDockerClient struct{}
func (a *AbraDockerClient) GetStackServices(ctx context.Context, dockerclient dockerClient.APIClient, namespace string) ([]swarm.Service, error) {
return dockerclient.ServiceList(ctx, types.ServiceListOptions{Filters: a.getStackServiceFilter(namespace)})
}
func (a *AbraDockerClient) getStackFilter(namespace string) filters.Args {
filter := filters.NewArgs()
filter.Add("label", convert.LabelNamespace+"="+namespace)
return filter
}
func (a *AbraDockerClient) getStackServiceFilter(namespace string) filters.Args {
return a.getStackFilter(namespace)
}
type MockDockerClient struct {
mock.Mock
}
func (a *MockDockerClient) GetStackServices(ctx context.Context, dockerclient dockerClient.APIClient, namespace string) ([]swarm.Service, error) {
callArgs := []interface{}{ctx}
callArgs = append(callArgs, dockerclient)
callArgs = append(callArgs, namespace)
args := a.Called(callArgs...)
return args.Get(0).([]swarm.Service), args.Error(1)
}
func (a *MockDockerClient) getStackFilter(namespace string) filters.Args {
callArgs := []interface{}{namespace}
args := a.Called(callArgs...)
return args.Get(0).(filters.Args)
}
func (a *MockDockerClient) getStackServiceFilter(namespace string) filters.Args {
callArgs := []interface{}{namespace}
args := a.Called(callArgs...)
return args.Get(0).(filters.Args)
}
-35
View File
@@ -1,35 +0,0 @@
package external
import (
appPkg "coopcloud.tech/abra/pkg/app"
"github.com/stretchr/testify/mock"
)
type RecipePackage interface {
EnsureExists(appPkg.App) error
Tags(appPkg.App) ([]string, error)
}
type AbraRecipePackage struct{}
func (a *AbraRecipePackage) EnsureExists(app appPkg.App) error {
return app.Recipe.EnsureExists()
}
func (a *AbraRecipePackage) Tags(app appPkg.App) ([]string, error) {
return app.Recipe.Tags()
}
type MockRecipePackage struct {
mock.Mock
}
func (a *MockRecipePackage) EnsureExists(app appPkg.App) error {
callArgs := []interface{}{app}
args := a.Called(callArgs...)
return args.Error(0)
}
func (a *MockRecipePackage) Tags(app appPkg.App) ([]string, error) {
callArgs := []interface{}{app}
args := a.Called(callArgs...)
return args.Get(0).([]string), args.Error(1)
}
-72
View File
@@ -1,72 +0,0 @@
package external
import (
"log"
"sync"
"time"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"github.com/stretchr/testify/mock"
)
type ServerPackage interface {
GetReachableServers(apps []appPkg.App) (map[string]bool, error)
}
type AbraServerPackage struct{}
func (a *AbraServerPackage) GetReachableServers(apps []appPkg.App) (map[string]bool, error) {
// servers that are unreachable..
reachable := make(map[string]bool)
servers, err := config.ReadServerNames()
if err != nil {
return nil, err
}
start := time.Now()
reachableCh := make(chan string)
//errCh := make(chan error)
var wg sync.WaitGroup
wg.Add(len(servers))
for _, server := range servers {
// check if its unreachable..
// this connection is retried, so takes 10 seconds per connection
// TODO: figure out a way to make this faster
// this is retried every server and connection so it really should be quicker
go func(server string) {
defer wg.Done()
timeout := client.WithTimeout(3)
if _, err := client.New(server, timeout); err != nil {
//errCh <- err
} else {
reachableCh <- server
}
}(server)
}
go func() {
for server := range reachableCh {
reachable[server] = true
}
}()
wg.Wait()
elapsed := time.Since(start)
log.Printf("Checking server availability took %s", elapsed)
return reachable, nil
}
type MockServerPackage struct {
mock.Mock
}
func (a *MockServerPackage) GetReachableServers(apps []appPkg.App) (map[string]bool, error) {
callArgs := []interface{}{}
for _, arg := range apps {
callArgs = append(callArgs, arg)
}
args := a.Called(callArgs...)
return args.Get(0).(map[string]bool), args.Error(1)
}
-233
View File
@@ -1,233 +0,0 @@
package cli
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sort"
"strings"
"time"
"github.com/samber/lo"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/tagcmp"
)
func (h *abraHandler) handleListApps(w http.ResponseWriter, r *http.Request) {
output, err := h.cmd.Run("abra", "app", "ls", "-m")
if err != nil {
InternalServerError(w, err)
return
}
var resp ServerAppsResponse
// no filtering
listAppServer := ""
recipeFilter := ""
if err := json.Unmarshal(output, &resp); err != nil {
InternalServerError(w, fmt.Errorf("invalid JSON from CLI"))
return
}
// Get abra apps from remote
apps, err := h.ap.GetApps(recipeFilter, listAppServer)
if err != nil {
InternalServerError(w, err)
}
start := time.Now()
// Returns map of all servers that are reachable/don't time out
reachable, err := h.sp.GetReachableServers(apps)
if err != nil {
InternalServerError(w, err)
}
elapsed := time.Since(start)
log.Printf("Checking server availability took %s", elapsed)
// TODO: I think this will check every server for every app, need to remove some first.
// rapps is all reachable apps
rapps := lo.Filter(apps, func(app appPkg.App, index int) bool {
_, ok := reachable[app.Server]
return ok
})
statuses := make(map[string]map[string]string)
statuses, err = h.ap.GetAppStatuses(rapps, true)
if err != nil {
InternalServerError(w, err)
}
// TODO: if a server is unreachable, skip its apps, maybe flag something?
for _, app := range apps {
var stats ServerApps
stats = resp[app.Server]
var appStats *AbraApp
for _, sapp := range stats.Apps {
if sapp.AppName == app.Name {
appStats = &sapp
}
}
_, ok := reachable[app.Server]
if !ok {
appStats.Chaos = "unknown"
appStats.ChaosVersion = "unknown"
appStats.Version = "unknown"
appStats.Status = "Server unreachable"
for i, sapp := range stats.Apps {
if sapp.AppName == app.Name {
stats.Apps[i] = *appStats
}
}
resp[app.Server] = stats
continue
}
if app.Recipe.Name == "backup-bot-two" {
stats.BackupEnabled = true
}
status := "unknown"
version := "unknown"
chaos := "unknown"
chaosVersion := "unknown"
if statusMeta, ok := statuses[h.ap.StackName(app)]; ok {
if currentVersion, exists := statusMeta["version"]; exists {
if currentVersion != "" {
version = currentVersion
}
}
if chaosDeploy, exists := statusMeta["chaos"]; exists {
chaos = chaosDeploy
}
if chaosDeployVersion, exists := statusMeta["chaosVersion"]; exists {
chaosVersion = chaosDeployVersion
}
if statusMeta["status"] != "" {
status = statusMeta["status"]
}
stats.VersionCount++
} else {
stats.UnversionedCount++
}
stats.AppCount++
appStats.Status = status
appStats.Chaos = chaos
appStats.ChaosVersion = chaosVersion
appStats.Version = version
localApp := true
var newUpdates []string
if version != "unknown" && chaos == "false" {
if err := h.rp.EnsureExists(app); err != nil {
InternalServerError(w, fmt.Errorf("unable to clone %s: %s", app.Name, err))
return
}
updates, err := h.rp.Tags(app)
if err != nil {
localApp = false
} else {
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
log.Fatal(err)
}
for _, update := range updates {
if ok := tagcmp.IsParsable(update); !ok {
log.Printf("unable to parse %s, skipping as upgrade option", update)
continue
}
parsedUpdate, err := tagcmp.Parse(update)
if err != nil {
log.Fatal(err)
}
if update != version && parsedUpdate.IsGreaterThan(parsedVersion) {
newUpdates = append(newUpdates, update)
}
}
}
}
if len(newUpdates) == 0 {
if version == "unknown" {
appStats.Upgrade = "unknown"
} else if localApp {
appStats.Upgrade = "latest"
stats.LatestCount++
} else {
appStats.Upgrade = "unknown"
}
} else {
newUpdates = SortVersionsDesc(newUpdates)
appStats.Upgrade = strings.Join(newUpdates, "\n")
stats.UpgradeCount++
}
for i, sapp := range stats.Apps {
if sapp.AppName == app.Name {
stats.Apps[i] = *appStats
}
}
resp[app.Server] = stats
}
jsonBytes, err := json.Marshal(resp)
if err != nil {
InternalServerError(w, fmt.Errorf("JSON conversion failed: %s\n", err))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}
func (h *abraHandler) handleListServers(w http.ResponseWriter, r *http.Request) {
abraServers, err := h.cmd.Run("abra", "server", "ls", "-m")
if err != nil {
InternalServerError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(abraServers)
}
func (h *abraHandler) handleListVersions(w http.ResponseWriter, r *http.Request, appName string) {
app, err := appPkg.Get(appName)
if err != nil {
InternalServerError(w, err)
return
}
versions, err := app.Recipe.Tags()
if err != nil {
InternalServerError(w, err)
return
}
jsonBytes, err := json.Marshal(versions)
if err != nil {
InternalServerError(w, err)
return
}
log.Printf("%v", versions)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}
func SortVersionsDesc(versions []string) []string {
var tags []tagcmp.Tag
for _, v := range versions {
parsed, _ := tagcmp.Parse(v) // skips unsupported tags
tags = append(tags, parsed)
}
sort.Sort(tagcmp.ByTagDesc(tags))
var desc []string
for _, t := range tags {
desc = append(desc, t.String())
}
return desc
}
-173
View File
@@ -1,173 +0,0 @@
package cli
import (
"coop-cloud-backend/cli/external"
"coop-cloud-backend/data"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"testing"
appPkg "coopcloud.tech/abra/pkg/app"
"github.com/stretchr/testify/assert"
)
func ToAny[T any](s []T) []any {
out := make([]any, len(s))
for i, v := range s {
out[i] = v
}
return out
}
// The mock call flow is
// h.cmd.Run -> h.ap.getApps -> h.ap.GetReachableServers -> h.ap.GetAppStatuses
// -(for each reachable app)> h.ap.StackName(app)
// -(reachable apps with version != "unknown" && chaos == "false")> h.rp.EnsureExists(app) and h.rp.Tags(app)
func TestListApps(t *testing.T) {
tests := []struct {
name string
// these are all of our "external" package function calls
getAppsArgs []string
getAppsOut []appPkg.App
getAppsErr error
getServsArgs []appPkg.App
getServsOut map[string]bool
getServsErr error
getStatusesArgs []interface{}
getStatusesOut map[string]map[string]string
getStatusesErr error
stackNameArgs []appPkg.App
stackNameOut []string
// these are arrays for all apps
ensureArgs []appPkg.App
ensureErrs []error
tagsArgs []appPkg.App
tagsOuts [][]string
tagsErrs []error
// these are the details for h.cmd.Run's output. Luckily the input isalways fixed
reqBody string
runOutput []byte
runErr error
// this is the final information i guess, but i need the final json thingy too
expectedBody ServerAppsResponse
expectedCode int
}{
{
name: "success one simple app",
getAppsArgs: []string{"", ""},
getAppsOut: []appPkg.App{data.App()},
getServsArgs: []appPkg.App{data.App()},
getServsOut: map[string]bool{"example.com": true},
getStatusesArgs: []interface{}{data.App(), true},
getStatusesOut: data.AppStatus(),
stackNameArgs: []appPkg.App{data.App()},
stackNameOut: []string{"nextcloud"},
ensureArgs: []appPkg.App{data.App()},
ensureErrs: []error{nil},
tagsArgs: []appPkg.App{data.App()},
tagsOuts: [][]string{{"1.2.0"}},
tagsErrs: []error{nil},
runOutput: []byte(`
{
"example.com": {
"apps": [{
"server": "example.com",
"recipe": "nextcloud",
"appName": "nextcloud.example.com",
"domain": "nextcloud.example.com",
"status": "",
"chaos": "",
"chaosVersion": "",
"version": "",
"upgrade": ""
}]
}
}
`),
expectedCode: http.StatusOK,
expectedBody: ServerAppsResponse{
"example.com": {
Apps: []AbraApp{AbraApp{
Server: "example.com",
Recipe: "nextcloud",
AppName: "nextcloud.example.com",
Domain: "nextcloud.example.com",
Status: "running",
Chaos: "false",
ChaosVersion: "unknown",
Version: "1.0.0",
Upgrade: "1.2.0",
}},
AppCount: 1,
VersionCount: 1,
UnversionedCount: 0,
LatestCount: 0,
UpgradeCount: 1,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockCmd := new(MockCommandRunner)
mockAppPkg := new(external.MockAppPackage)
mockServerPkg := new(external.MockServerPackage)
mockRecipePkg := new(external.MockRecipePackage)
mockCmd.
On("Run", "abra", "app", "ls", "-m").
Return(tt.runOutput, tt.runErr)
mockAppPkg.
On("GetApps", ToAny(tt.getAppsArgs)...).
Return(tt.getAppsOut, tt.getAppsErr)
mockServerPkg.
On("GetReachableServers", ToAny(tt.getServsArgs)...).
Return(tt.getServsOut, tt.getServsErr)
mockAppPkg.
On("GetAppStatuses", tt.getStatusesArgs...).
Return(tt.getStatusesOut, tt.getStatusesErr)
for i := range tt.stackNameArgs {
mockAppPkg.
On("StackName", []interface{}{tt.stackNameArgs[i]}...).
Return(tt.stackNameOut[i])
}
for i, ensureArg := range tt.ensureArgs {
mockRecipePkg.
On("EnsureExists", ensureArg).
Return(tt.ensureErrs[i])
log.Printf("tag output %v", tt.tagsOuts[i])
mockRecipePkg.
On("Tags", tt.tagsArgs[i]).
Return(tt.tagsOuts[i], tt.tagsErrs[i])
}
h := &abraHandler{cmd: mockCmd, ap: mockAppPkg, sp: mockServerPkg, rp: mockRecipePkg}
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
h.handleListApps(rec, req)
assert.Equal(t, tt.expectedCode, rec.Code)
var got ServerAppsResponse
if assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) {
assert.Equal(t, tt.expectedBody, got)
}
// may need to have "expectedRun" flags later
mockCmd.AssertExpectations(t)
mockAppPkg.AssertExpectations(t)
mockServerPkg.AssertExpectations(t)
mockRecipePkg.AssertExpectations(t)
})
}
}
-86
View File
@@ -1,86 +0,0 @@
package cli
import (
"context"
"log"
"fmt"
"net/http"
appPkg "coopcloud.tech/abra/pkg/app"
"coop-cloud-backend/cli/status"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/docker/api/types"
)
func (h *abraHandler) handleGetLogs(w http.ResponseWriter, r *http.Request, appName string, serviceName string) {
app, err := appPkg.Get(appName)
if err != nil {
InternalServerError(w, err)
return
}
stackName := app.StackName()
if err := app.Recipe.EnsureExists(); err != nil {
InternalServerError(w, err)
return
}
cl, err := client.New(app.Server)
if err != nil {
InternalServerError(w, err)
return
}
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
if err != nil {
InternalServerError(w, err)
return
}
if !deployMeta.IsDeployed {
log.Printf("%s is not deployed?", app.Name)
return
}
serviceNames := []string{serviceName}
f, err := app.Filters(true, false, serviceNames...)
if err != nil {
InternalServerError(w, err)
}
ctx := r.Context()
services, err := cl.ServiceList(
context.Background(),
types.ServiceListOptions{Filters: f},
)
if err != nil {
InternalServerError(w, err)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
logCh := make (chan string, 50)
go status.StreamLogs(ctx, cl, services, logCh)
for {
select{
case <- ctx.Done():
log.Printf("log streaming done")
return
case line, ok := <- logCh:
if !ok {
return
}
fmt.Fprintf(w, "data: %s\n\n", line)
flusher.Flush()
}
}
}
-90
View File
@@ -1,90 +0,0 @@
package cli
// Represents an App, follows the format of
// https://git.coopcloud.tech/toolshed/coop-cloud-front
type AbraApp struct {
Server string `json:"server"`
Recipe string `json:"recipe`
AppName string `json:"appName"`
Domain string `json:"domain"`
Status string `json:"status"`
Chaos string `json:"chaos"`
ChaosVersion string `json:"chaosVersion"`
Version string `json:"version"`
Upgrade string `json:"upgrade"`
}
type AbraServer struct {
Name string `json:"name"`
Host string `json:"host"`
}
type ServerAppsResponse map[string]ServerApps
type ServerApps struct {
Apps []AbraApp `json:"apps"`
AppCount int `json:"appCount"`
VersionCount int `json:"versionCount"`
UnversionedCount int `json:"unversionedCount"`
LatestCount int `json:"latestCount"`
UpgradeCount int `json:"upgradeCount"`
BackupEnabled bool `json:"backupEnabled"`
}
type DeployState struct {
AppName string `json:"appName"`
Streams []DeployStream `json:"streams"`
Logs []string `json:"logs"`
Failed bool `json:"failed"`
TimedOut bool `json:"timedOut"`
Quit bool `json:"quit"`
Count int `json:"count"`
}
type DeployStream struct {
Name string `json:"Name"`
id string `json:"id"`
status string `json:"status"`
retries int `json:"retries"`
health string `json:"health"`
rollback bool `json:"rollback"`
}
type AppSecret struct {
Name string `json:"name"`
Version string `json:"version"`
Value string `json:"value"`
Created bool `json:"created"`
}
type AbraAppService struct {
Service string `json:"service"`
Chaos bool `json:"chaos"`
Created string `json:"created"`
Image string `json:"image"`
Ports string `json:"ports"`
State string `json:"state"`
Status string `json:"status"`
Version string `json:"version"`
}
type AppSnapshot struct {
CreatedAt string `json:"createdAt"`
Snapshot string `json:"snapshot"`
Version string `json:"version"`
}
// type RecipeMeta struct {
// Category string `json:"category"`
// DefaultBranch string `json:"default_branch"`
// Description string `json:"description"`
// Features Features `json:"features"`
// Icon string `json:"icon"`
// Name string `json:"name"`
// Repository string `json:"repository"`
// SSHURL string `json:"ssh_url"`
// Versions RecipeVersions `json:"versions"`
// Website string `json:"website"`
// }
// type RecipeCatalogue map[Name]RecipeMeta
-119
View File
@@ -1,119 +0,0 @@
package cli
import (
"encoding/json"
"fmt"
"log"
"net/http"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/secret"
)
func (h *abraHandler) handleNewApp(w http.ResponseWriter, r *http.Request, appName string) {
args := []string{"app", "new", appName, "-n"}
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields() // catch unwanted fields
// anonymous struct type: handy for one-time use
body := struct {
Domain *string `json:"domain"`
Server *string `json:"server"`
Chaos *bool `json:"chaos"`
Secrets *bool `json:"secrets"`
}{}
err := d.Decode(&body)
if err != nil {
// bad JSON or unrecognized json field
log.Printf("%v", err)
BadRequest(w, err)
return
}
if body.Domain == nil {
http.Error(w, "missing field 'domain' from JSON object", http.StatusBadRequest)
return
}
if body.Server == nil {
http.Error(w, "missing field 'server' from JSON object", http.StatusBadRequest)
return
}
args = append(args, fmt.Sprintf("--domain=%s", *body.Domain))
args = append(args, fmt.Sprintf("--server=%s", *body.Server))
if body.Chaos != nil && *body.Chaos == true {
args = append(args, "-C")
}
log.Printf("%v", args)
output, err := h.cmd.Run("abra", args...)
if err != nil {
InternalServerError(w, fmt.Errorf("Error: %s", string(output)))
return
}
var fs []AppSecret
if body.Secrets != nil && *body.Secrets == true {
log.Printf("testing: %v %v", *body.Domain, *body.Server)
appSecrets, err := h.secret.createSecrets(appName, *body.Domain, *body.Server)
if err != nil {
InternalServerError(w, err)
return
}
for k, v := range appSecrets {
fs = append(fs, AppSecret{
Name: k,
Value: v,
Version: "v1",
Created: true,
})
}
}
jsonBytes, err := json.Marshal(fs)
if err != nil {
InternalServerError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}
type SecretUtilsWrapper interface {
createSecrets(appName string, domain string, server string) (map[string]string, error)
}
type SecretUtils struct{}
func (s *SecretUtils) createSecrets(appName string, domain string, server string) (map[string]string, error) {
cl, err := client.New(server)
if err != nil {
return nil, err
}
recipe := recipe.Get(appName)
sampleEnv, err := recipe.SampleEnv()
if err != nil {
return nil, err
}
composeFiles, err := recipe.GetComposeFiles(sampleEnv)
if err != nil {
return nil, err
}
//sanitisedAppName := appPkg.SanitiseAppName(domain)
secretsConfig, err := secret.ReadSecretsConfig(
recipe.SampleEnvPath,
composeFiles,
appPkg.StackName(domain),
)
if err != nil {
return nil, err
}
secrets, err := secret.GenerateSecrets(cl, secretsConfig, server)
if err != nil {
return nil, err
}
return secrets, nil
}
-123
View File
@@ -1,123 +0,0 @@
package cli
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type MockSecretUtils struct {
mock.Mock
}
func (su *MockSecretUtils) createSecrets(appName string, domain string, server string) (map[string]string, error) {
args := su.Called(appName, domain, server)
return args.Get(0).(map[string]string), args.Error(1)
}
func TestHandleNewApp(t *testing.T) {
tests := []struct {
name string
reqBody string
runOutput []byte
runErr error
runSecrets bool
expectedSecretArgs []string
secretOutput map[string]string
secretErr error
expectedArgs []string
expectedRun bool
expectedCode int
}{
{
name: "success no chaos no secrets",
reqBody: `{
"domain": "app.xyz",
"server": "server.xyz",
"chaos": false,
"secrets": false
}`,
runOutput: []byte(nil),
expectedArgs: []string{"app", "new", "myapp", "-n", "--domain=app.xyz", "--server=server.xyz"},
expectedRun: true,
expectedCode: http.StatusOK,
},
{
name: "success with chaos no secrets",
reqBody: `{
"domain": "app.xyz",
"server": "server.xyz",
"chaos": true,
"secrets": false
}`,
runOutput: []byte(nil),
expectedArgs: []string{"app", "new", "myapp", "-n", "--domain=app.xyz", "--server=server.xyz", "-C"},
expectedRun: true,
expectedCode: http.StatusOK,
},
{
name: "success no chaos with secrets",
reqBody: `{
"domain": "app.xyz",
"server": "server.xyz",
"chaos": false,
"secrets": true
}`,
runSecrets: true,
expectedSecretArgs: []string{"myapp", "app.xyz", "server.xyz"},
secretOutput: map[string]string{
"name": "secret",
"value": "secret-value",
},
runOutput: []byte(nil),
expectedArgs: []string{"app", "new", "myapp", "-n", "--domain=app.xyz", "--server=server.xyz"},
expectedRun: true,
expectedCode: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockCmd := new(MockCommandRunner)
mockSecrets := new(MockSecretUtils)
args := []interface{}{"abra"}
for _, arg := range tt.expectedArgs {
args = append(args, arg)
}
secretArgs := []interface{}{}
for _, arg := range tt.expectedSecretArgs {
secretArgs = append(secretArgs, arg)
}
mockCmd.
On("Run", args...).
Return(tt.runOutput, tt.runErr)
if tt.runSecrets {
mockSecrets.
On("createSecrets", secretArgs...).
Return(tt.secretOutput, tt.secretErr)
}
h := &abraHandler{cmd: mockCmd, secret: mockSecrets}
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.reqBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.handleNewApp(rec, req, "myapp")
assert.Equal(t, tt.expectedCode, rec.Code)
if tt.expectedRun {
mockCmd.AssertExpectations(t)
}
if tt.runSecrets {
mockSecrets.AssertExpectations(t)
}
})
}
}
-44
View File
@@ -1,44 +0,0 @@
package cli
import (
"encoding/json"
"net/http"
"sort"
)
func (h *abraHandler) handleGetAppServices(w http.ResponseWriter, r *http.Request, appName string) {
var tmpMsg map[string]map[string]string
output, err := h.cmd.Run("abra", "app", "ps", appName, "-m")
if err != nil {
InternalServerError(w, err)
return
}
if err = json.Unmarshal(output, &tmpMsg); err != nil {
InternalServerError(w, err)
return
}
var services []AbraAppService
for _, v := range tmpMsg {
services = append(services, AbraAppService{
Service: v["service"],
Chaos: v["chaos"] == "true",
Created: v["created"],
Image: v["image"],
Ports: v["ports"],
State: v["state"],
Status: v["status"],
Version: v["version"],
})
}
sort.Slice(services, func(i, j int) bool {
return services[i].Service < services[j].Service
})
jsonBytes, err := json.Marshal(services)
if err != nil {
InternalServerError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}
-119
View File
@@ -1,119 +0,0 @@
package cli
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestHandlePing(t *testing.T) {
tests := []struct {
name string
runOutput []byte
runErr error
expectedArgs []string
expectedBody []AbraAppService
expectedCode int
}{
{
name: "success",
runOutput: []byte(`
{"app":{
"chaos":"false",
"created":"3 days ago",
"image":"image:4.1.0-22",
"ports":"1880/tcp",
"service":"app",
"state":"running",
"status":"Up 3 days (healthy)",
"version":"1.0.0+4.1.0-22"}
}
`),
expectedArgs: []string{"app", "ps", "myapp", "-m"},
expectedBody: []AbraAppService{{
Service: "app",
Chaos: false,
Created: "3 days ago",
Image: "image:4.1.0-22",
Ports: "1880/tcp",
State: "running",
Status: "Up 3 days (healthy)",
Version: "1.0.0+4.1.0-22",
}},
expectedCode: http.StatusOK,
},
{
name: "success sorted",
runOutput: []byte(`
{"app":{
"chaos":"false",
"created":"3 days ago",
"image":"image:4.1.0-22",
"ports":"1880/tcp",
"service":"app",
"state":"running",
"status":"Up 3 days (healthy)",
"version":"1.0.0+4.1.0-22"
},
"base": {
"chaos":"false",
"created":"3 days ago",
"image":"image:6.1.0",
"ports":"1880/tcp",
"service":"base",
"state":"running",
"status":"Up 3 days (healthy)",
"version":"1.0.0+6.1.0"
}
}`),
expectedArgs: []string{"app", "ps", "myapp", "-m"},
expectedBody: []AbraAppService{{
Service: "app",
Chaos: false,
Created: "3 days ago",
Image: "image:4.1.0-22",
Ports: "1880/tcp",
State: "running",
Status: "Up 3 days (healthy)",
Version: "1.0.0+4.1.0-22",
}, {
Service: "base",
Chaos: false,
Created: "3 days ago",
Image: "image:6.1.0",
Ports: "1880/tcp",
State: "running",
Status: "Up 3 days (healthy)",
Version: "1.0.0+6.1.0",
}},
expectedCode: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockCmd := new(MockCommandRunner)
args := []interface{}{"abra"}
for _, arg := range tt.expectedArgs {
args = append(args, arg)
}
mockCmd.
On("Run", args...).
Return(tt.runOutput, tt.runErr)
h := &abraHandler{cmd: mockCmd}
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
h.handleGetAppServices(rec, req, "myapp")
assert.Equal(t, tt.expectedCode, rec.Code)
mockCmd.AssertExpectations(t)
})
}
}
-15
View File
@@ -1,15 +0,0 @@
package cli
import (
"fmt"
"net/http"
)
func (h *abraHandler) handleRemoveApp(w http.ResponseWriter, r *http.Request, appName string) {
output, err := h.cmd.Run("abra", "app", "remove", appName, "-n")
if err != nil {
InternalServerError(w, fmt.Errorf("Error: %s", string(output)))
return
}
w.WriteHeader(http.StatusOK)
}
-102
View File
@@ -1,102 +0,0 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
)
func (h *abraHandler) handleGetAppSecrets(w http.ResponseWriter, r *http.Request, appName string) {
log.Printf("handling secrets!")
secrets, err := h.cmd.Run("abra", "app", "secret", "list", appName, "-m")
if err != nil {
log.Printf("??")
InternalServerError(w, err)
return
}
resp := []struct {
Created *string `json:"created on server"`
GenName *string `json:"generated name"`
Name *string `json:"name"`
Version *string `json:"version"`
}{}
err = json.Unmarshal(secrets, &resp)
if err != nil {
log.Printf("???")
InternalServerError(w, err)
return
}
fs := []AppSecret{}
for _, secret := range resp {
if secret.Created == nil {
InternalServerError(w, errors.New("Failed to decode field 'created on server' from abra response"))
return
}
if secret.Name == nil {
InternalServerError(w, errors.New("Failed to decode field 'name' from abra response"))
return
}
if secret.Version == nil {
InternalServerError(w, errors.New("Failed to decode field 'version' from abra response"))
return
}
fs = append(fs, AppSecret{
Name: *secret.Name,
Version: *secret.Version,
Created: *secret.Created == "true",
Value: "",
})
}
log.Printf("%v", fs)
jsonBytes, err := json.Marshal(fs)
if err != nil {
InternalServerError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}
func (h *abraHandler) handleInsertAppSecret(w http.ResponseWriter, r *http.Request, appName string) {
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields() // catch unwanted fields
// anonymous struct type: handy for one-time use
body := struct {
Name *string `json:"name"`
Version *string `json:"version"`
Value *string `json:"value"`
}{}
err := d.Decode(&body)
if err != nil {
log.Printf("%v", err)
BadRequest(w, err)
return
}
if body.Name == nil {
BadRequest(w, errors.New("missing field 'name' from JSON object"))
return
}
if body.Version == nil {
BadRequest(w, errors.New("missing field 'version' from JSON object"))
return
}
if body.Value == nil {
BadRequest(w, errors.New("missing field 'value' from JSON object"))
return
}
log.Printf("%s %s %s", *body.Name, *body.Version, *body.Value)
output, err := h.cmd.Run("abra", "app", "secret", "insert", appName, *body.Name, *body.Version, *body.Value)
if err != nil {
InternalServerError(w, fmt.Errorf("Error: %s", string(output)))
return
}
w.WriteHeader(http.StatusOK)
}
-215
View File
@@ -1,215 +0,0 @@
package cli
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHandleGetSecrets(t *testing.T) {
tests := []struct {
name string
runOutput []byte
runErr error
expectedArgs []string
expectedCode int
expectedBody []AppSecret
}{
{
name: "success no secrets",
runOutput: []byte(`[]`),
expectedArgs: []string{"app", "secret", "list", "myapp", "-m"},
expectedCode: http.StatusOK,
expectedBody: []AppSecret{},
},
{
name: "success with secrets",
runOutput: []byte(`[
{
"created on server": "true",
"generated name": "myapp_secret",
"name": "secret",
"version": "v1"
},
{
"created on server": "false",
"generated name": "myapp_unknown",
"name": "unknown",
"version": "v1"
}
]`),
expectedArgs: []string{"app", "secret", "list", "myapp", "-m"},
expectedCode: http.StatusOK,
expectedBody: []AppSecret{
{
Name: "secret",
Version: "v1",
Value: "",
Created: true},
{
Name: "unknown",
Version: "v1",
Value: "",
Created: false},
},
},
{
name: "failure bad json",
runOutput: []byte(`
[
{
"created on server": "true",
}
]`),
expectedArgs: []string{"app", "secret", "list", "myapp", "-m"},
expectedCode: http.StatusInternalServerError,
},
{
name: "failure missing fields",
runOutput: []byte(`
[
{
"created on server": "true",
"generated name": "myapp_fail"
}
]`),
expectedArgs: []string{"app", "secret", "list", "myapp", "-m"},
expectedCode: http.StatusInternalServerError,
},
{
name: "failure null field",
runOutput: []byte(`
[
{
"created on server": null,
"generated name": "myapp_nil",
"name": "nil",
"version": "v1"
}
]`),
expectedArgs: []string{"app", "secret", "list", "myapp", "-m"},
expectedCode: http.StatusInternalServerError,
},
{
name: "command failure",
runOutput: []byte("error"),
runErr: errors.New("failed"),
expectedArgs: []string{"app", "secret", "list", "myapp", "-m"},
expectedCode: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockCmd := new(MockCommandRunner)
args := []interface{}{"abra"}
for _, arg := range tt.expectedArgs {
args = append(args, arg)
}
mockCmd.
On("Run", args...).
Return(tt.runOutput, tt.runErr)
h := &abraHandler{cmd: mockCmd}
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
h.handleGetAppSecrets(rec, req, "myapp")
assert.Equal(t, tt.expectedCode, rec.Code)
if tt.expectedCode == http.StatusOK {
var res []AppSecret
err := json.Unmarshal(rec.Body.Bytes(), &res)
require.NoError(t, err)
assert.Equal(t, tt.expectedBody, res)
}
mockCmd.AssertExpectations(t)
})
}
}
func TestHandleInsert(t *testing.T) {
tests := []struct {
name string
reqBody string
runOutput []byte
runErr error
expectedArgs []string
expectedRun bool
expectedCode int
}{
{
name: "success",
reqBody: `{
"name": "myapp_secret",
"version": "v1",
"value": "secret"
}`,
runOutput: []byte(nil),
expectedArgs: []string{"app", "secret", "insert", "myapp", "myapp_secret", "v1", "secret"},
expectedRun: true,
expectedCode: http.StatusOK,
},
{
name: "failure missing fields",
reqBody: `{
"name": "myapp_secret",
}`,
runOutput: []byte(nil),
expectedArgs: []string{"app", "secret", "insert", "myapp", "myapp_secret", "v1", "secret"},
expectedRun: false,
expectedCode: http.StatusBadRequest,
},
{
name: "failure abra",
reqBody: `{
"name": "myapp_secret",
"version": "v1",
"value": "secret"
}`,
runOutput: []byte(nil),
runErr: errors.New("error"),
expectedArgs: []string{"app", "secret", "insert", "myapp", "myapp_secret", "v1", "secret"},
expectedRun: true,
expectedCode: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockCmd := new(MockCommandRunner)
args := []interface{}{"abra"}
for _, arg := range tt.expectedArgs {
args = append(args, arg)
}
mockCmd.
On("Run", args...).
Return(tt.runOutput, tt.runErr)
h := &abraHandler{cmd: mockCmd}
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.reqBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.handleInsertAppSecret(rec, req, "myapp")
assert.Equal(t, tt.expectedCode, rec.Code)
if tt.expectedRun {
mockCmd.AssertExpectations(t)
}
})
}
}
-305
View File
@@ -1,305 +0,0 @@
package status
import (
"context"
"os"
"io"
"log"
"fmt"
"strings"
"time"
"encoding/json"
dockerClient "github.com/docker/docker/client"
containerTypes "github.com/docker/docker/api/types/container"
"github.com/docker/cli/cli/command/service/progress"
"github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/docker/api/types/filters"
"coopcloud.tech/abra/pkg/ui"
)
type Event interface {
ServiceID() string
}
type ProgressEvent struct {
Service ServiceState
Err error
Failed bool
}
func (e ProgressEvent) ServiceID() string { return e.Service.Id }
type HealthEvent struct {
Service ServiceState
Err error
Health string
}
func (e HealthEvent) ServiceID() string { return e.Service.Id }
type StatusEvent struct {
Service ServiceState
Err error
JsonMsg jsonmessage.JSONMessage
}
func (e StatusEvent) ServiceID() string { return e.Service.Id }
type StreamEvent struct {
Type string `json:"type"` // "service" | "done"
Data interface{} `json:"data"`
}
type ServiceState struct {
Name string `json:"name"`
Err error `json:"err"`
Id string `json:"id"`
Status string `json:"status"`
Retries int `json:"retries"`
Health string `json:"health"`
Rollback bool `json:"rollback"`
Failed bool `json:"failed"`
}
type DeployState struct {
Count int `json:"count"`
Total int `json:"total"`
Failed bool `json:"failed"`
Quit bool `json:"quit"`
}
type DeployMsg int
const (
FailMsg DeployMsg = iota
CompleteMsg
QuitMsg
)
func (ds DeployState) complete() bool {
return ds.Count == ds.Total
}
func progressProducer(ctx context.Context, cl *dockerClient.Client, s ServiceState, w *io.PipeWriter, ch chan<- Event) {
go func() {
log.Printf("producing progress...")
err := progress.ServiceProgress(ctx, cl, s.Id, w)
log.Printf("got some service progress here")
if err != nil {
log.Printf("Error in service progress")
ch <- ProgressEvent{
Service: s,
Failed: true,
Err: err,
}
} else {
ch <- ProgressEvent{
Service: s,
}
}
}()
}
func statusProducer(ctx context.Context, decoder *json.Decoder, s ServiceState, ch chan<- Event) {
go func() {
log.Printf("producing status...")
for {
var msg jsonmessage.JSONMessage
if err := decoder.Decode(&msg); err != nil {
if err == io.EOF {
return
}
ch <- StatusEvent{
Service: s,
Err: err,
}
return
}
log.Printf("Maybe writing JSON message")
msg.Display(os.Stdout, false);
ch <- StatusEvent{
Service: s,
JsonMsg: msg,
}
}
}()
}
func healthProducer(ctx context.Context, cl *dockerClient.Client, s ServiceState, ch chan<- Event) {
go func() {
log.Printf("producing health...")
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
health := ""
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s", s.Name))
containers, err := cl.ContainerList(ctx, containerTypes.ListOptions{Filters: filters})
if err != nil {
ch <- HealthEvent{Service: s, Err: err}
continue
}
if len(containers) == 0 {
ch <- HealthEvent{Service: s}
continue
}
containerState, err := cl.ContainerInspect(ctx, containers[0].ID)
if err != nil {
ch <- HealthEvent{Service: s, Err: err}
continue
}
if containerState.State.Health != nil {
health = containerState.State.Health.Status
}
ch <- HealthEvent{
Service: s,
Health: health,
}
}
}
}()
}
func processEvent(ctx context.Context, events <- chan Event, info chan <- DeployMsg, s ServiceState, stream chan <- StreamEvent) error {
for {
select {
case event := <- events:
if event.ServiceID() != s.Id {
continue
}
switch event := event.(type) {
case ProgressEvent:
if event.Err != nil {
s.Err = event.Err
log.Printf("Error in progress event: %s", s.Err)
}
if event.Failed {
info <- FailMsg
}
// print for debugging purposes
log.Printf("Service: %s is complete", event.Service.Id)
// end print
info <- CompleteMsg
case StatusEvent:
if event.Err != nil {
s.Err = event.Err
log.Printf("Error in status event: %s", s.Err)
}
// print for debugging purposes
b, err := json.MarshalIndent(event.JsonMsg, "", " ")
if err != nil {
log.Printf("Problem with json message: %s", err)
}
log.Printf(string(b))
// end print
if event.JsonMsg.ID == "rollback" {
log.Printf("Failed in rollback")
s.Failed = true
s.Rollback = true
}
if event.JsonMsg.ID != "overall progress" {
newStatus := strings.ToLower(event.JsonMsg.Status)
currentStatus := s.Status
if !strings.Contains(currentStatus, "starting") &&
strings.Contains(newStatus, "starting") {
s.Retries += 1
}
if s.Rollback {
if event.JsonMsg.ID == "rollback" {
s.Status = newStatus
}
} else {
s.Status = newStatus
}
}
case HealthEvent:
if event.Err != nil {
s.Err = event.Err
log.Printf("Error in health event: %s", s.Err)
}
h := "?"
if s.Health != "" {
h = s.Health
}
if event.Health != "" {
h = event.Health
}
s.Health = h
}
stream <- StreamEvent{Type: "service", Data: s}
case <- ctx.Done():
return ctx.Err()
}
}
}
func processService(ctx context.Context, info chan <- DeployMsg, cl *dockerClient.Client, s ServiceState, decoder *json.Decoder, writer *io.PipeWriter, stream chan <- StreamEvent) {
events := make (chan Event, 50)
progressProducer(ctx, cl, s, writer, events)
statusProducer(ctx, decoder, s, events)
healthProducer(ctx, cl, s, events)
go processEvent(ctx, events, info, s, stream)
}
func WaitOnServices(pctx context.Context, cl *dockerClient.Client, services []ui.ServiceMeta, filters filters.Args, stream chan <- StreamEvent) {
ctx, cancel := context.WithCancel(pctx)
defer cancel()
log.Printf("What???")
ds := DeployState{
Count: 0,
Total: len(services),
Failed: false,
Quit: false,
}
info := make (chan DeployMsg, 50)
for _, service := range services {
r, w := io.Pipe()
d := json.NewDecoder(r)
s := ServiceState{
Name: service.Name,
Id: service.ID,
Retries: -1,
Health: "?",
}
log.Printf("Processing Service: %s", s.Id)
processService(ctx, info, cl, s, d, w, stream)
}
for {
select {
case msg := <-info:
switch msg {
case FailMsg:
ds.Failed = true
case CompleteMsg:
ds.Count += 1
if ds.complete() {
log.Printf("deploy completed")
cancel()
}
case QuitMsg:
ds.Quit = true
cancel()
}
case <-ctx.Done():
log.Printf("%v", ds)
stream <- StreamEvent{Type: "done", Data: ds}
log.Printf("Context finished because: %s", ctx.Err())
return
}
}
}
-57
View File
@@ -1,57 +0,0 @@
package status
import (
"bufio"
"context"
"fmt"
"sync"
"github.com/docker/docker/api/types/swarm"
dockerClient "github.com/docker/docker/client"
containerTypes "github.com/docker/docker/api/types/container"
)
func StreamLogs(
ctx context.Context,
cl *dockerClient.Client,
services []swarm.Service,
logCh chan <- string,
) error {
waitCh := make(chan struct{})
var wg sync.WaitGroup
for _, service := range services {
wg.Add(1)
go func(serviceID string) {
defer wg.Done()
tail := "50"
logs, err := cl.ServiceLogs(ctx, serviceID, containerTypes.LogsOptions{
ShowStderr: true,
ShowStdout: true,
Until: "",
Timestamps: true,
Follow: true,
Tail: tail,
Details: false,
})
if err == nil {
buf := bufio.NewScanner(logs)
for buf.Scan() {
select {
case <- ctx.Done():
return
default:
line := fmt.Sprintf("%s: %s", service.Spec.Name, buf.Text())
logCh <- line
}
}
logs.Close()
return
}
}(service.ID)
}
wg.Wait()
close(waitCh)
close(logCh)
return nil
}
-66
View File
@@ -1,66 +0,0 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
)
func (h *abraHandler) handleUpgradeApp(w http.ResponseWriter, r *http.Request, appName string, force bool) {
log.Printf("Handling App Upgrade!")
args := []string{"app", "upgrade", appName, "-n", "-c"}
if force {
args = append(args, "-f")
}
output, err := h.cmd.Run("abra", args...)
if err != nil {
InternalServerError(w, errors.New(fmt.Sprintf("Error: %s", string(output))))
return
}
log.Printf("Finishing app upgrade!")
w.WriteHeader(http.StatusOK)
}
func (h *abraHandler) handleRollbackApp(w http.ResponseWriter, r *http.Request, appName string, force bool) {
log.Printf("Handling App rollback!")
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
body := struct {
Version *string `json:"version"`
}{}
err := d.Decode(&body)
if err != nil {
log.Printf("error: %v", err)
BadRequest(w, err)
return
}
if body.Version == nil {
BadRequest(w, fmt.Errorf("Error: no version for rollback"))
return
}
args := []string{"app", "rollback", appName, "-n", "-c"}
if force {
args = append(args, "-f")
}
if body.Version != nil {
if *body.Version == "Auto" {
log.Printf("leaving abra to determine rollback version")
} else {
args = append(args, *body.Version)
}
}
output, err := h.cmd.Run("abra", args...)
if err != nil {
InternalServerError(w, fmt.Errorf("Error: %s", string(output)))
return
}
log.Printf("Finishing app rollback!")
w.WriteHeader(http.StatusOK)
}
-168
View File
@@ -1,168 +0,0 @@
package cli
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestHandleUpgradeApp(t *testing.T) {
tests := []struct {
name string
force bool
runOutput []byte
runErr error
expectedArgs []string
expectedCode int
}{
{
name: "success",
force: false,
runOutput: []byte("ok"),
expectedArgs: []string{"app", "upgrade", "myapp", "-n", "-c"},
expectedCode: http.StatusOK,
},
{
name: "success with force",
force: true,
runOutput: []byte("ok"),
expectedArgs: []string{"app", "upgrade", "myapp", "-n", "-c", "-f"},
expectedCode: http.StatusOK,
},
{
name: "command failure",
force: false,
runOutput: []byte("error"),
runErr: errors.New("failed"),
expectedArgs: []string{"app", "upgrade", "myapp", "-n", "-c"},
expectedCode: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockCmd := new(MockCommandRunner)
args := []interface{}{"abra"}
for _, arg := range tt.expectedArgs {
args = append(args, arg)
}
mockCmd.
On("Run", args...).
Return(tt.runOutput, tt.runErr)
h := &abraHandler{cmd: mockCmd}
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
h.handleUpgradeApp(rec, req, "myapp", tt.force)
assert.Equal(t, tt.expectedCode, rec.Code)
mockCmd.AssertExpectations(t)
})
}
}
func TestHandleRollbackApp(t *testing.T) {
tests := []struct {
name string
body string
force bool
runOutput []byte
runErr error
expectedArgs []string
expectedCode int
expectedRun bool
}{
{
name: "success with auto version no force",
body: `{
"version": "Auto"
}`,
force: false,
runOutput: []byte("ok"),
expectedArgs: []string{"app", "rollback", "myapp", "-n", "-c"},
expectedCode: http.StatusOK,
expectedRun: true,
},
{
name: "success with auto version with force",
body: `{
"version": "Auto"
}`,
force: true,
runOutput: []byte("ok"),
expectedArgs: []string{"app", "rollback", "myapp", "-n", "-c", "-f"},
expectedCode: http.StatusOK,
expectedRun: true,
},
{
name: "failure no body",
body: `{
}`,
force: false,
runOutput: []byte("ok"),
expectedArgs: []string{"app", "rollback", "myapp", "-n", "-c"},
expectedCode: http.StatusBadRequest,
expectedRun: false,
},
{
name: "failure unknown field",
body: `{
"unknown": "unknown"
}`,
force: false,
expectedArgs: []string{"app", "rollback", "myapp", "-n", "-c"},
expectedCode: http.StatusBadRequest,
expectedRun: false,
},
{
name: "failure abra error",
body: `{
"version": "1.0.0"
}`,
force: false,
runOutput: []byte("error"),
runErr: errors.New("failed"),
expectedArgs: []string{"app", "rollback", "myapp", "-n", "-c", "1.0.0"},
expectedCode: http.StatusInternalServerError,
expectedRun: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockCmd := new(MockCommandRunner)
args := []interface{}{"abra"}
for _, arg := range tt.expectedArgs {
args = append(args, arg)
}
if tt.expectedRun {
mockCmd.
On("Run", args...).
Return(tt.runOutput, tt.runErr).
Once()
}
h := &abraHandler{cmd: mockCmd}
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.handleRollbackApp(rec, req, "myapp", tt.force)
assert.Equal(t, tt.expectedCode, rec.Code)
mockCmd.AssertExpectations(t)
})
}
}
-7
View File
@@ -1,7 +0,0 @@
#!/bin/bash
export PATH=/home/node/.local/bin/:$PATH
/home/node/wizard/gobackend &
npm run dev -- --host 0.0.0.0 --port 5173
-99
View File
@@ -1,99 +0,0 @@
package data
import (
"fmt"
"coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/recipe"
)
type AppModifier func(*app.App)
// func App() app.App {
// return app.App{
// Name: "nextcloud",
// Domain: "cloud.example.com",
// Server: "server.example.com",
// Path: "/home/test/.abra/servers/hetzner/cloud.example.com.env",
// Env: envfile.AppEnv{
// "DOMAIN": "cloud.example.com",
// "LETS_ENCRYPT_ENABLED": "1",
// },
// Recipe: recipe.Recipe{
// Name: "nextcloud",
// },
// }
// }
func App(modifiers ...AppModifier) app.App {
a := app.App{
Name: "nextcloud.example.com",
Domain: "cloud.example.com",
Server: "example.com",
Path: "/home/test/.abra/servers/hetzner/cloud.example.com.env",
Env: envfile.AppEnv{
"DOMAIN": "cloud.example.com",
"LETS_ENCRYPT_ENABLED": "1",
},
Recipe: recipe.Recipe{
Name: "nextcloud",
},
}
for _, mod := range modifiers {
mod(&a)
}
return a
}
func RecipeName(name string) AppModifier {
return func(a *app.App) {
domainName := fmt.Sprintf("%s.%s", name, a.Server)
a.Name = name
a.Domain = domainName
a.Recipe.Name = name
a.Env["DOMAIN"] = domainName
}
}
func Broken() AppModifier {
return func(a *app.App) {
delete(a.Env, "DOMAIN")
}
}
func WithServer(server string) AppModifier {
return func(a *app.App) {
a.Server = server
}
}
func WithDomain(domain string) AppModifier {
return func(a *app.App) {
a.Domain = domain
a.Env["DOMAIN"] = domain
}
}
// version, chaosVersion, chaos, status
type AppStatusModifier func(map[string]map[string]string)
func AppStatus(modifiers ...AppStatusModifier) map[string]map[string]string {
a := map[string]map[string]string{
"nextcloud": {
"version": "1.0.0",
"chaos": "false",
"chaosVersion": "unknown",
"status": "running",
},
}
for _, mod := range modifiers {
mod(a)
}
return a
}
func WithChaos(app string) AppStatusModifier {
return func(a map[string]map[string]string) {
a[app]["chaos"] = "true"
a[app]["chaosVersion"] = "version"
}
}
+11 -36
View File
@@ -5,9 +5,7 @@ go 1.25.6
require (
coopcloud.tech/abra v0.0.0-20260305102834-9d401202b4fb
github.com/charmbracelet/log v0.4.2
github.com/docker/cli v28.4.0+incompatible
github.com/docker/docker v28.5.2+incompatible
github.com/gorilla/websocket v1.4.0
)
require (
@@ -38,11 +36,10 @@ require (
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/containers/image v3.0.2+incompatible // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cyphar/filepath-securejoin v0.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decentral1se/passgen v1.0.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v28.4.0+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
@@ -50,7 +47,6 @@ require (
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -62,7 +58,6 @@ require (
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
@@ -76,12 +71,9 @@ require (
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leonelquinteros/gotext v1.7.2 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -90,16 +82,9 @@ require (
github.com/miekg/pkcs11 v1.1.1 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/sys/mountinfo v0.6.2 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/signal v0.7.1 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
@@ -111,53 +96,43 @@ require (
github.com/opencontainers/runtime-spec v1.1.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/schollz/progressbar/v3 v3.18.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.5 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/cobra v1.10.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
github.com/testcontainers/testcontainers-go v0.43.0 // indirect
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.8.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.13.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect
-68
View File
@@ -116,8 +116,6 @@ github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -169,8 +167,6 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4=
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
@@ -215,8 +211,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-sql-driver/mysql v1.3.0 h1:pgwjLi/dvffoP9aabwkT3AKpXQM93QARkjFhDDqC1UE=
github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -248,7 +242,6 @@ github.com/google/certificate-transparency-go v1.0.10-0.20180222191210-5ab67e519
github.com/google/certificate-transparency-go v1.0.10-0.20180222191210-5ab67e519c93/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -260,7 +253,6 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
@@ -308,8 +300,6 @@ github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7Dmvb
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -329,13 +319,9 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.5.3 h1:C8fxWnhYyME3n0klPOhVM7PtYUB3eV1W3DeFmN3j53Y=
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -368,29 +354,14 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0=
github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
@@ -446,8 +417,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
@@ -492,8 +461,6 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/schollz/progressbar/v3 v3.14.4 h1:W9ZrDSJk7eqmQhd3uxFNNcTr0QL+xuGNI9dEMrw0r74=
github.com/schollz/progressbar/v3 v3.14.4/go.mod h1:aT3UQ7yGm+2ZjeXPqsjTenwL3ddUiuZ0kfQ/2tHlyNI=
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
@@ -502,8 +469,6 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
@@ -511,8 +476,6 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
@@ -545,10 +508,6 @@ github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/y
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -558,17 +517,10 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI=
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c=
github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnHqbFDGRSlXPO0AhYWw=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
@@ -587,8 +539,6 @@ github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
@@ -600,8 +550,6 @@ go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0 h1:U2guen0GhqH8o/G2un8f/aG/y++OuW6MyCo6hT9prXk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0/go.mod h1:yeGZANgEcpdx/WK0IvvRFC+2oLiMS2u4L/0Rj2M2Qr0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
@@ -620,8 +568,6 @@ go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6b
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
@@ -634,8 +580,6 @@ go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
@@ -662,8 +606,6 @@ golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=
@@ -695,8 +637,6 @@ golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -715,12 +655,10 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -739,8 +677,6 @@ golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -752,8 +688,6 @@ golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -765,8 +699,6 @@ golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+1 -1
View File
@@ -14,7 +14,7 @@ var (
DontWaitConverge bool
Dry bool
Force bool
Secrets bool
MachineReadable bool
Major bool
Minor bool
NoDomainChecks bool
-15
View File
@@ -1,15 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"packageRules": [
{
"matchDatasources": [
"docker"
],
"pinDigests": true
}
]
}
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
/home/node/wizard/gobackend &
npm run dev -- --host
Submodule
+1
Submodule web added at 08510b136e
-3
View File
@@ -1,3 +0,0 @@
VITE_API_URL=http://localhost:3000/api
VITE_WS_URL=ws://localhost:3000
VITE_MOCK_AUTH=true
-25
View File
@@ -1,25 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
pnpm-lock.yaml
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-2
View File
@@ -1,2 +0,0 @@
pnpm 10.20.0
nodejs 22.12.0
-8
View File
@@ -1,8 +0,0 @@
{
"hash": "97e400e8",
"configHash": "539cfeba",
"lockfileHash": "e3b0c442",
"browserHash": "b743bb36",
"optimized": {},
"chunks": {}
}
-3
View File
@@ -1,3 +0,0 @@
{
"type": "module"
}
-52
View File
@@ -1,52 +0,0 @@
# Coop Cloud Front
Frontend for Coop Cloud — a web UI wrapper around the `abra` CLI for
managing VPSs, containers and application deployments.
This repository contains a Vite + React + TypeScript app styled
with SCSS.
## Quick start
- Install dependencies (pnpm is recommended):
pnpm install
pnpm dev
```
The app uses Vite; start the dev server with `pnpm dev`.
## Environment
- The app reads runtime flags from Vite env variables. The default build uses mocked api data.
-To deploy on a real API without mocking, run:
pnpm start:prod
Modify `.env` file at the project root if you need to override values for
development (see Vite docs for env var conventions).
## Scripts
- `pnpm dev` — start dev server
- `pnpm build` — run TypeScript and build production assets
- `pnpm preview` — preview production build locally
- `pnpm lint` — run ESLint
- `pnpm lint:scss` — run Stylelint
- `pnpm format` — format with Prettier
## Notes about local development
- The repo contains mock JSON and a `mockApi` service to develop UI without
a backend. Toggle mock mode with `VITE_MOCK_AUTH=true`.
- Routes are client-side using `react-router-dom`.
## Next steps / suggested work items
- Add form validation and better UX for `RecipeForm`.
- Improve accessibility.
- Add unit tests.
- Improve responsive layouts and card grid behaviour on narrow screens.
- Add deployment docs and production environment variables.
---
-50
View File
@@ -1,50 +0,0 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
// TypeScript specific
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}],
'@typescript-eslint/no-explicit-any': 'warn',
// Import organization
'sort-imports': ['warn', {
ignoreCase: true,
ignoreDeclarationSort: true,
}],
// React best practices
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// Code quality
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'warn',
'no-var': 'error',
},
},
);
-17
View File
@@ -1,17 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>coopcloud-frontend</title>
<link rel="preload" href="/fonts/manrope.light.woff2" as="font" type="font/woff2">
<link rel="preload" href="/fonts/manrope.medium.woff2" as="font" type="font/woff2">
<link rel="preload" href="/fonts/manrope.bold.woff2" as="font" type="font/woff2">
<link rel="preload" href="/fonts/Lora-Italic.woff2" as="font" type="font/woff2">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-5178
View File
File diff suppressed because it is too large Load Diff
-49
View File
@@ -1,49 +0,0 @@
{
"name": "coopcloud-front",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"start:prod": "VITE_MOCK_AUTH=false pnpm start",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts,tsx --fix",
"lint:scss": "stylelint '**/*.scss'",
"lint:scss:fix": "stylelint '**/*.scss' --fix",
"format": "prettier --write 'src/**/*.{ts,tsx,scss,css,json}'",
"format:check": "prettier --check 'src/**/*.{ts,tsx,scss,css,json}'"
},
"dependencies": {
"lucide-react": "^1.47.0",
"niceware": "^4.0.0",
"niceware-ts": "^0.0.5",
"prismjs": "^1.30.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.6.0",
"react-router-dom": "^7.9.5",
"react-simple-code-editor": "^0.14.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"sass": "^1.93.3",
"stylelint": "^16.25.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^16.0.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}
-4
View File
@@ -1,4 +0,0 @@
allowBuilds:
'@parcel/watcher': true
esbuild: true
inotify: false
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-28
View File
@@ -1,28 +0,0 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { Dashboard } from './routes/Dashboard/Dashboard';
import { Apps } from './routes/Apps/Apps';
import { AppDetail } from './routes/Apps/App';
import { Servers } from './routes/Servers/Servers';
import { Server } from './routes/Servers/Server';
import { Recipes } from './routes/Recipes/Recipes';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/apps" element={<Apps />} />
<Route path="/apps/:server/:appName" element={<AppDetail />} />
<Route path="/servers" element={<Servers />} />
<Route path="/servers/:serverName" element={<Server />} />
<Route path="/recipes" element={<Recipes />} />
{/* 404 catch-all */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
);
}
export default App;
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 163 KiB

-31
View File
@@ -1,31 +0,0 @@
@font-face {
font-family: 'Lora';
src: url('/fonts/Lora-Italic.woff2') format('woff2');
font-style: italic;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: 'Manrope';
src: url('/fonts/manrope.light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Manrope';
src: url('/fonts/manrope.medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Manrope';
src: url('/fonts/manrope.bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
-287
View File
@@ -1,287 +0,0 @@
@use './variables' as *;
@use './mixins' as *;
body {
margin: 0;
padding: 0;
width: 100%;
min-height: 100vh;
}
#root {
width: 100%;
min-height: 100vh;
}
// global page layout styles
.page-wrapper {
min-height: 100vh;
background-color: $bg-secondary;
}
.page-content {
max-width: 1600px;
margin: 0 auto;
padding: $spacing-2xl $spacing-xl;
@media (max-width: 768px) {
padding: $spacing-xl $spacing-md;
}
}
.page-header {
margin-bottom: $spacing-2xl;
h1 {
font-size: $font-size-3xl;
margin: 0 0 $spacing-sm;
color: $text-primary;
}
.subtitle {
font-size: $font-size-lg;
color: $text-secondary;
margin: 0;
}
}
// global state styles
.loading,
.error {
text-align: center;
padding: $spacing-3xl;
font-size: $font-size-lg;
}
.loading {
color: $text-secondary;
}
.error {
color: $error;
}
// Stats grid and cards
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $spacing-lg;
margin-bottom: $spacing-2xl;
}
.stat-card {
@include card;
display: flex;
align-items: center;
gap: $spacing-lg;
padding: $spacing-xl;
transition: transform $transition-base, box-shadow $transition-base;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-lg;
}
// Modifier classes for colored borders
&.upgrade {
border-left: 4px solid $primary-light;
cursor: none;
}
&.chaos {
border-left: 4px solid $primary-dark;
}
&.primary {
border-left: 4px solid $primary;
}
.stat-icon {
font-size: 2rem;
}
.stat-info {
flex: 1;
.stat-number {
font-size: $font-size-3xl;
font-weight: $font-weight-bold;
color: $text-primary;
margin: 0;
line-height: 1;
}
.stat-label {
font-size: $font-size-sm;
color: $text-secondary;
margin: $spacing-xs 0 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
// Filters component
.filters {
@include card;
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
margin-bottom: $spacing-xl;
@media (max-width: 768px) {
flex-direction: column;
}
.search-input {
background-color: $bg-primary;
flex: 1;
min-width: 250px;
padding: $spacing-sm $spacing-md;
border: 2px solid $border-color;
border-radius: $radius-md;
font-size: $font-size-base;
transition: border-color $transition-base;
color: $text-primary;
&::placeholder {
color: $text-muted;
}
&:focus {
outline: none;
border-color: $primary;
}
}
select {
padding: $spacing-sm $spacing-md;
border: 2px solid $border-color;
border-radius: $radius-md;
font-size: $font-size-base;
background-color: $bg-primary;
color: $text-primary;
cursor: pointer;
transition: border-color $transition-base;
&:focus {
outline: none;
border-color: $primary;
}
}
.checkbox-filter {
display: flex;
align-items: center;
gap: $spacing-sm;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
cursor: pointer;
width: 18px;
height: 18px;
}
span {
font-size: $font-size-sm;
color: $text-primary;
}
}
}
// Status badges
.status-badge {
display: inline-block;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
text-transform: capitalize;
&.status-deployed {
background-color: rgba($success, 0.1);
color: $success;
}
&.status-stopped {
background-color: rgba($text-muted, 0.1);
color: $text-muted;
}
&.status-error {
background-color: rgba($error, 0.1);
color: $error;
}
}
// global badge styles
.recipe-badge,
.server-badge {
display: inline-block;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-sm;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
}
.recipe-badge {
background-color: rgba($info, 0.1);
color: $info;
}
.server-badge {
background-color: rgba($text-secondary, 0.1);
color: $text-secondary;
}
// Ensure stat-chip shows primary-dark outline on hover and when active
.stat-chip {
&:hover:not(:disabled),
&.active {
border-color: $primary-dark;
box-shadow: 0 0 0 3px rgba($primary-dark, 0.08);
}
}
.filter-chip {
cursor: default;
}
// Results count
.results-count {
text-align: center;
color: $text-secondary;
font-size: $font-size-sm;
padding: $spacing-md;
}
// No results message
.no-results {
@include card;
text-align: center;
padding: $spacing-3xl;
color: $text-secondary;
grid-column: 1 / -1;
}
// Navigation link button (for clickable cards)
.nav-link {
all: unset;
cursor: pointer;
width: 100%;
display: block;
&:focus-visible {
outline: 2px solid $primary;
outline-offset: 2px;
border-radius: $radius-md;
}
}
.bland-button {
background: none;
border: none;
padding: 0;
margin: 0;
}
-212
View File
@@ -1,212 +0,0 @@
@use 'variables' as *;
// Flexbox center
@mixin flex-center {
display: flex;
justify-content: center;
align-items: center;
}
// Responsive breakpoints
@mixin respond-to($breakpoint) {
@if $breakpoint == 'sm' {
@media (min-width: $breakpoint-sm) { @content; }
} @else if $breakpoint == 'md' {
@media (min-width: $breakpoint-md) { @content; }
} @else if $breakpoint == 'lg' {
@media (min-width: $breakpoint-lg) { @content; }
} @else if $breakpoint == 'xl' {
@media (min-width: $breakpoint-xl) { @content; }
}
}
// Card style
@mixin card {
background: $bg-primary;
box-shadow: $shadow-md;
padding: $spacing-xl;
}
// Ensure cards occupy consistent vertical space and can stretch horizontally
@mixin card-dimensions($min-width: 320px, $min-height: 180px) {
min-width: $min-width;
min-height: $min-height;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
/// Standard vertical stack for card-style list rows (dashboard recent apps, server detail apps, etc.)
@mixin card-list-vertical($gap: $spacing-md) {
display: flex;
flex-direction: column;
gap: $gap;
}
/// Hover lift used by dashboard list cards, server grid cards, and similar surfaces
@mixin card-hover-lift($translate-y: -2px, $hover-shadow: $shadow-lg) {
transition: transform $transition-base, box-shadow $transition-base;
&:hover {
transform: translateY($translate-y);
box-shadow: $hover-shadow;
}
}
/// Card shell with horizontal scroll (e.g. data tables)
@mixin scrollable-card {
@include card;
overflow-x: auto;
}
/// Header block inside a card: title area with a bottom rule (server cards, etc.)
@mixin card-header-rule {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
border-bottom: 2px solid $bg-secondary;
}
/// Primary title + muted monospace/meta line (server cards, resource headers)
@mixin card-title-stack(
$title-size: $font-size-xl,
$title-weight: $font-weight-bold,
$meta-size: $font-size-sm
) {
h3 {
margin: 0 0 $spacing-xs;
font-size: $title-size;
color: $text-primary;
font-weight: $title-weight;
}
.server-host {
font-size: $meta-size;
color: $text-muted;
font-family: monospace;
}
}
/// Label + value row inside a card body (server stats, etc.)
@mixin card-stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-sm 0;
border-bottom: 1px solid $bg-secondary;
&:last-child {
border-bottom: none;
}
}
/// Full-width edge bleed for highlighted rows inside padded cards (matches card horizontal padding)
@mixin card-row-highlight-bleed($clear-bottom-border: true) {
padding: $spacing-sm $spacing-md;
margin: $spacing-sm (-$spacing-xl);
padding-left: calc($spacing-xl + $spacing-md);
padding-right: calc($spacing-xl + $spacing-md);
@if $clear-bottom-border {
border-bottom: none;
}
}
// Button base
@mixin button-base {
padding: $spacing-sm $spacing-lg;
border: none;
border-radius: $radius-md;
font-weight: $font-weight-semibold;
cursor: pointer;
transition: all $transition-base;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
// Gradient background
@mixin gradient-primary {
background: linear-gradient(135deg, $primary 0%, $primary-light 100%);
}
// Truncate text
@mixin truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Status badge
@mixin status-badge($color) {
padding: $spacing-xs $spacing-md;
border-radius: $radius-full;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
background-color: rgba($color, 0.1);
// color: darken($color, 20%);
}
// Compact chip used for stat chips and small interactive chips
@mixin chip(
$bg: white,
$border-color: $border-color,
$radius: $radius-md,
$padding: $spacing-md $spacing-lg,
$gap: $spacing-md,
$hover-translate: -2px,
$hover-shadow: $shadow-sm,
$font-size: $font-size-base
) {
display: flex;
align-items: center;
gap: $gap;
padding: $padding;
background: $bg;
border: 2px solid $border-color;
border-radius: $radius;
cursor: pointer;
transition: all $transition-base;
font-size: $font-size;
&:hover:not(:disabled) {
border-color: $primary;
transform: translateY($hover-translate);
box-shadow: $hover-shadow;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// Reusable action button styles; accepts border width and radius
@mixin action-btn($border-width: 1px, $radius: $radius-sm, $padding: $spacing-xs $spacing-md, $font-size: $font-size-sm) {
background: none;
border: $border-width solid $border-color;
padding: $padding;
border-radius: $radius;
cursor: pointer;
font-size: $font-size;
color: $text-primary;
font-weight: $font-weight-medium;
transition: all $transition-base;
&:hover {
background-color: rgba($primary, 0.05);
color: $text-primary;
transform: translateY(-1px);
border-color: $primary;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
-76
View File
@@ -1,76 +0,0 @@
// Colors
$primary: #EFEFEF;
$primary-dark: #6A9CFF;
$primary-light: #ff4f88;
$secondary: #363636;
$success: #10b981;
$warning: #f59e0b;
$error: #ef4444;
$info: #3b82f6;
$text-primary: #363636;
$text-secondary: #4a4a4a
;
$text-muted: #999;
$bg-primary: #ffffff;
$bg-secondary: #f5f5f5;
$bg-tertiary: #e0e0e0;
$border-color: #e0e0e0;
// Spacing
$spacing-2xs: 0.1rem;
$spacing-xs: 0.25rem;
$spacing-sm: 0.5rem;
$spacing-md: 1rem;
$spacing-lg: 1.5rem;
$spacing-xl: 2rem;
$spacing-2xl: 3rem;
$spacing-3xl: 4rem;
// Typography
$font-family-body: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
$font-family-heading: 'Lora', serif;
$font-size-xs: 0.75rem;
$font-size-sm: 0.875rem;
$font-size-base: 1rem;
$font-size-lg: 1.125rem;
$font-size-xl: 1.25rem;
$font-size-2xl: 1.5rem;
$font-size-3xl: 2rem;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
// Border radius
$radius-sm: 4px;
$radius-md: 6px;
$radius-lg: 8px;
$radius-xl: 12px;
$radius-full: 9999px;
// Shadows
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
$shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1);
$shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15);
$shadow-xl: 0 8px 16px rgba(0, 0, 0, 0.2);
// Transitions
$transition-fast: 0.15s ease;
$transition-base: 0.2s ease;
$transition-slow: 0.3s ease;
// Breakpoints
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
-7
View File
@@ -1,7 +0,0 @@
// 1. Configuration & helpers (no CSS output)
@use 'variables';
@use 'mixins';
@use 'fonts';
// 2. Global base styles
@use 'global';
-52
View File
@@ -1,52 +0,0 @@
import React, { useEffect, useState, useRef } from "react";
import { apiService } from '../../services/api';
import Editor from "react-simple-code-editor";
import Prism from "prismjs";
// import a theme + language
import "prismjs/themes/prism.css";
import "prismjs/components/prism-javascript";
interface EditorProps {
appName: string;
}
export const EditorPage: React.FC<EditorProps> = ({ appName }) => {
const [code, setCode] = useState("");
useEffect(() => {
document.body.classList.add("modal-open");
return () => {
document.body.classList.remove("modal-open");
};
}, []);
useEffect(() => {
const fetchCode = async () => {
const config = await apiService.getConfig(appName);
setCode(config);
}
fetchCode();
}, []);
const saveFile = async () => {
await apiService.writeConfig(appName, code)
};
return (
<div>
<Editor
value={code}
onValueChange={code => setCode(code)}
highlight={code => Prism.highlight(code, Prism.languages.javascript, "javascript")}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 14,
border: "1px solid #ccc",
minHeight: "300px",
}}
/>
<button onClick={saveFile}>Save</button>
</div>
);
};
-150
View File
@@ -1,150 +0,0 @@
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
@use "sass:color";
.secrets {
color: $text-primary
}
.status {
white-space: nowrap;
min-width: 350px;
.manage-secret {
display: flex;
align-items: center;
gap: $spacing-sm;
&.created {
color: $success;
}
&.not-created {
color: $error;
}
}
}
.gen-btn {
@include action-btn(1px, $radius-md, $spacing-xs $spacing-md, $font-size-sm);
white-space: nowrap;
&.auto {
padding: $spacing-xs;
border: 1px solid #d0d7de;
border-radius: 6px;
background: #fff;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover:not(:disabled) {
background: #f3f4f6;
}
}
&.create {
margin-left: auto;
display: flex;
gap: 8px;
background: $success;
color: white;
border-color: $success;
&:hover:not(:disabled) {
background: color.adjust($success, $lightness: -10%);
}
}
&.modify {
margin-left: auto;
display: flex;
gap: 8px;
background: $warning;
color: white;
border-color: $warning;
&:hover:not(:disabled) {
background: color.adjust($warning, $lightness: -10%);
}
}
&.cancel {
background: $error;
color: white;
border-color: $error;
&:hover:not(:disabled) {
background: color.adjust($error, $lightness: -10%);
}
}
}
.secret-input-wrapper {
position: relative;
display: flex;
align-items: center;
width: 100%;
max-width: 420px;
}
.secret-input {
flex: 1;
min-width: 350px;
padding: $spacing-xs;
padding-right: calc(
16px +
16px +
6px +
16px
);
font-size: $font-size-sm;
border: 1px solid #d0d7de;
border-radius: $radius-md;
background: #fff;
color: #24292f;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.secret-input::placeholder {
color: #8b949e;
}
.secret-input:focus {
outline: none;
border-color: #2f81f7;
box-shadow: 0 0 0 3px rgba(47, 129, 247, 0.15);
}
.right-icons {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 6px;
z-index: 1;
}
.right-icons button {
all: unset;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
background: transparent;
cursor: pointer;
color: #6b7280;
border-radius: 4px;
transition: background-color 0.15s ease;
}
.right-icons button:hover {
color: #37393b;
}
-208
View File
@@ -1,208 +0,0 @@
import React, { useEffect, useState, useRef } from "react";
import { apiService } from '../../services/api';
import type { AbraAppSecret } from '../../types'
import './Secrets.scss';
import {generatePassphrase} from "niceware-ts";
import { FaCopy, FaEye, FaEyeSlash, FaDice } from "react-icons/fa";
interface SecretProps {
appName: string;
}
export const SecretsPage: React.FC<SecretProps> = ({ appName }) => {
const [secrets, setSecrets] = useState<AbraAppSecret[]>();
const [editingSecrets, setEditingSecrets] = useState(new Set());
const [secretValues, setSecretValues] = useState({});
const [hidden, setHidden] = useState(new Set());
const [loadingSecrets, setLoadingSecrets] = useState(new Set());
const [ellipses, setEllipses] = useState("");
useEffect(() => {
const interval = setInterval(() => {
setEllipses(prev => {
if (prev.length >= 3) return "";
return prev + ".";
});
}, 500);
return () => clearInterval(interval);
}, []);
const copyToClipboard = async (value: string) => {
if (!value) return;
await navigator.clipboard.writeText(value);
};
useEffect(() => {
document.body.classList.add("modal-open");
return () => {
document.body.classList.remove("modal-open");
};
}, []);
useEffect(() => {
const fetchSecrets = async () => {
const secretInfo = await apiService.getSecrets(appName);
setSecrets(secretInfo);
}
fetchSecrets();
}, []);
const generateSecret = (name: string) => {
setSecretValues(prev => ({
...prev,
[name]: generatePassphrase(12).join("-"),
}))
};
const generateOrUpdateSecret = async (name: string, version: string, value: string) => {
// TODO: handle updating secret instead of generating
console.log(appName, name, version, value);
setEditingSecrets(prev => {
const next = new Set(prev);
next.delete(name);
return next;
});
setSecretValues(prev => {
const next = { ...prev };
delete next[name];
return next;
});
if (hidden.has(name)) {
setHidden(prev => {
const next = new Set(prev);
next.delete(name);
return next;
});
}
setLoadingSecrets(prev => new Set(prev).add(name));
try {
await apiService.writeSecret(appName, name, version, value);
const secretInfo = await apiService.getSecrets(appName);
setSecrets(secretInfo);
} catch (err) {
// TODO: better error handling
console.log("Error in setting new secret")
}
setLoadingSecrets(prev => {
const next = new Set(prev);
next.delete(name);
return next;
});
};
return (
<div className="modal">
{(secrets == null && <p> Fetching Secrets...</p>)}
{(secrets != null && secrets.length == 0 && <p> Found no secrets </p>)}
{(secrets != null && secrets.length > 0 &&
<div className="secrets">
<table>
<thead>
<tr>
<th>Secret Name</th>
<th> Version </th>
<th> Status </th>
</tr>
</thead>
<tbody>
{secrets.map((secret) => (
<tr key={secret.name}>
<td>{secret.name}</td>
<td>{secret.version}</td>
<td className="status">
{loadingSecrets.has(secret.name) && <div> <span>modifying secret{ellipses}</span></div>}
{!loadingSecrets.has(secret.name) && (<div className={secret.created ? "manage-secret created" : "manage-secret not-created"}>
{!editingSecrets.has(secret.name) && !loadingSecrets.has(secret.name) && <span> {secret.created ? "created" : "not created"}</span> }
{editingSecrets.has(secret.name) &&
<div className="secret-input-wrapper">
<button
type="button"
className="gen-btn auto"
title="Click to generate automatically"
onClick={() => {generateSecret(secret.name)}}
>
Auto
</button>
<input
className="secret-input"
name="secret-value"
placeholder="Secret Value"
value={(hidden.has(secret.name) ? '*'.repeat(secretValues[secret.name].length): secretValues[secret.name] )|| ""}
onChange={(e) =>
setSecretValues(prev => ({
...prev,
[secret.name]: e.target.value,
}))
}
/>
<div className="right-icons">
<button
type="button"
title="Copy"
onClick={copyToClipboard(secretValues[secret.name])}
>
<FaCopy />
</button>
<button
type="button"
title={hidden.has(secret.name) ? "Show" : "Hide"}
onClick={() => setHidden((prev) => {
if(prev.has(secret.name)) {
const next = new Set(prev);
next.delete(secret.name);
return next;
}
return new Set(prev).add(secret.name);
})}
>
{hidden.has(secret.name) ? <FaEye /> : <FaEyeSlash />}
</button>
</div>
</div>
}
<button
className={secret.created ? "gen-btn modify" : "gen-btn create"}
onClick={() => editingSecrets.has(secret.name) ? generateOrUpdateSecret(secret.name, secret.version, secretValues[secret.name])
: setEditingSecrets(prev => new Set(prev).add(secret.name))}>
{secret.created ? "Update" : "Create"}
</button>
{ editingSecrets.has(secret.name) &&
<button
className="gen-btn cancel"
onClick={() => {
setEditingSecrets(prev => {
const next = new Set(prev);
next.delete(secret.name);
return next;
});
setSecretValues(prev => {
const next = { ...prev };
delete next[secret.name];
return next;
});
}}>
Cancel
</button>
}
</div>)}
</td>
<td>
<span>{secret.value}</span>
<button
className="copy-btn"
onClick={() => navigator.clipboard.writeText(secret.value)}
>
Copy
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>)}
</div>
);
};
-32
View File
@@ -1,32 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import './_Header.scss';
import logo from '../../assets/coopcloud_logo_grey.svg';
interface HeaderProps {
children: React.ReactNode;
}
export const Header: React.FC<HeaderProps> = ({ children }) => {
const navigate = useNavigate();
return(
<header className="layout-header">
<div className="header-content">
<h1 onClick={() => navigate('/dashboard')} className="logo">
<img className="logo" src={logo}/>
</h1>
<nav className="nav">
<button onClick={() => navigate('/apps')} className="nav-link">
Apps
</button>
<button onClick={() => navigate('/servers')} className="nav-link">
Servers
</button>
<button onClick={() => navigate('/recipes')} className="nav-link">
Recipes
</button>
</nav>
</div>
</header>
)}
-83
View File
@@ -1,83 +0,0 @@
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
.layout-header {
background-color: $primary-light;
color: $text-primary;
padding: $spacing-lg 0;
box-shadow: $shadow-lg;
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 0 $spacing-xl;
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-xl;
@media (max-width: 768px) {
flex-direction: column;
gap: $spacing-md;
}
}
.logo {
height: 100px;
margin: 0;
display: inline-block;
max-width: 100%;
cursor: pointer;
}
.nav {
display: flex;
gap: $spacing-sm;
flex: 1;
text-align: center;
@media (max-width: 768px) {
width: 100%;
justify-content: center;
}
}
.nav-link {
background: none;
border: none;
color: $text-primary;
padding: $spacing-sm $spacing-lg;
cursor: pointer;
border-radius: $radius-md;
transition: all $transition-base;
font-size: $font-size-xl;
font-weight: $font-weight-medium;
position: relative;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
// Active indicator
&.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 80%;
height: 2px;
background-color: $bg-primary;
}
@media (max-width: 768px) {
padding: $spacing-sm $spacing-md;
font-size: $font-size-xl;
}
}
}
-20
View File
@@ -1,20 +0,0 @@
@use '../../assets/scss/variables' as *;
.loader {
display: inline-flex; // instead of block
align-items: center; // center SVG inside
line-height: 1; // prevents extra vertical space
svg {
display: block;
}
rect {
fill: $primary-light;
}
&--style7 {
rect {
transform-origin: center bottom;
}
}
}
-35
View File
@@ -1,35 +0,0 @@
import "./Loader.scss";
export const Loader = () => {
return (
<div className="loader loader--style6" title="5">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="24px" height="30px" viewBox="0 0 24 30" style={{ enableBackground: "new 0 0 50 50" }} xml:space="preserve">
<rect x="0" y="13" width="4" height="5" fill="#333">
<animate attributeName="height" attributeType="XML"
values="5;21;5"
begin="0s" dur="0.6s" repeatCount="indefinite" />
<animate attributeName="y" attributeType="XML"
values="13; 5; 13"
begin="0s" dur="0.6s" repeatCount="indefinite" />
</rect>
<rect x="10" y="13" width="4" height="5" fill="#333">
<animate attributeName="height" attributeType="XML"
values="5;21;5"
begin="0.15s" dur="0.6s" repeatCount="indefinite" />
<animate attributeName="y" attributeType="XML"
values="13; 5; 13"
begin="0.15s" dur="0.6s" repeatCount="indefinite" />
</rect>
<rect x="20" y="13" width="4" height="5" fill="#333">
<animate attributeName="height" attributeType="XML"
values="5;21;5"
begin="0.3s" dur="0.6s" repeatCount="indefinite" />
<animate attributeName="y" attributeType="XML"
values="13; 5; 13"
begin="0.3s" dur="0.6s" repeatCount="indefinite" />
</rect>
</svg>
</div>
);
};
-43
View File
@@ -1,43 +0,0 @@
// magic spinner stuff
$offset: 187;
$duration: 1.4s;
.spinner {
margin-top: 10px;
animation: rotator $duration linear infinite;
}
@keyframes rotator {
0% { transform: rotate(0deg); }
100% { transform: rotate(270deg); }
}
.path {
stroke-dasharray: $offset;
stroke-dashoffset: 0;
transform-origin: center;
animation:
dash $duration ease-in-out infinite,
colors ($duration*4) ease-in-out infinite;
}
@keyframes colors {
0% { stroke: #4285F4; }
25% { stroke: #DE3E35; }
50% { stroke: #F7C223; }
75% { stroke: #1B9A59; }
100% { stroke: #4285F4; }
}
@keyframes dash {
0% { stroke-dashoffset: $offset; }
50% {
stroke-dashoffset: $offset/4;
transform:rotate(135deg);
}
100% {
stroke-dashoffset: $offset;
transform:rotate(450deg);
}
}
-9
View File
@@ -1,9 +0,0 @@
import "./Spinner.scss"
export const Spinner = () => {
return (
<svg className="spinner" width="40px" height="40px" viewBox="0 0 66 66" xmlns="http://www.w3.org/2000/svg">
<circle className="path" fill="none" stroke-width="6" stroke-linecap="round" cx="33" cy="33" r="30"></circle>
</svg>
);
};
-83
View File
@@ -1,83 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import type { LogEntry } from '../../services/mockApi'; // Import from mockApi (or api for real)
import './_Terminal.scss';
interface TerminalProps {
logs: LogEntry[];
isActive: boolean;
onClose?: () => void;
}
export const Terminal: React.FC<TerminalProps> = ({ logs, isActive, onClose }) => {
const terminalRef = useRef<HTMLDivElement>(null);
const indexRef = useRef(0);
const [displayedLogs, setDisplayedLogs] = useState<LogEntry[]>([]);
// Stream logs in with delays for realistic effect
useEffect(() => {
if (!isActive || logs.length === 0) {
indexRef.current = 0;
setDisplayedLogs([]);
return;
}
const streamLogs = () => {
if (indexRef.current < logs.length) {
setDisplayedLogs(prev => [...prev, logs[indexRef.current]]);
indexRef.current++;
// Variable delay based on log type
const delay = logs[indexRef.current - 1]?.type === 'command' ? 200 :
logs[indexRef.current - 1]?.type === 'output' ? 100 : 300;
setTimeout(streamLogs, delay);
}
};
streamLogs();
}, [logs, isActive]);
// Auto-scroll to bottom
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}
}, [displayedLogs]);
if (!isActive && displayedLogs.length === 0) {
return null;
}
const formatTime = (date: Date) => {
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
return (
<div className="terminal-container">
<div className="terminal-header">
<div className="terminal-controls">
<span className="terminal-control close" onClick={onClose}></span>
<span className="terminal-control minimize"></span>
<span className="terminal-control maximize"></span>
</div>
<div className="terminal-title">abra CLI</div>
<div className="terminal-spacer"></div>
</div>
<div className="terminal-content" ref={terminalRef}>
{displayedLogs.filter(log => log && log.type).map((log, index) => (
<div key={index} className={`terminal-line terminal-${log.type}`}>
<span className="terminal-text">{log.text}</span>
</div>
))}
{isActive && displayedLogs.length > 0 && (
<div className="terminal-cursor">▊</div>
)}
</div>
</div>
);
};
-204
View File
@@ -1,204 +0,0 @@
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
.terminal-container {
background: #1e1e1e;
border-radius: $radius-md;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
margin-bottom: $spacing-xl;
animation: terminal-appear 0.3s ease-out;
}
@keyframes terminal-appear {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.terminal-header {
display: flex;
align-items: center;
padding: $spacing-sm $spacing-md;
background: #323232;
border-bottom: 1px solid #404040;
user-select: none;
.terminal-controls {
display: flex;
gap: $spacing-xs;
min-width: 60px;
}
.terminal-control {
width: 12px;
height: 12px;
border-radius: 50%;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
&.close {
background: #ff5f56;
}
&.minimize {
background: #ffbd2e;
}
&.maximize {
background: #27c93f;
}
}
.terminal-title {
flex: 1;
text-align: center;
color: #a0a0a0;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
}
.terminal-spacer {
min-width: 60px;
}
}
.terminal-content {
padding: $spacing-md;
background: #1e1e1e;
color: #d4d4d4;
font-size: 13px;
line-height: 1.6;
max-height: 400px;
overflow-y: auto;
overflow-x: hidden;
/* Custom scrollbar */
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #2d2d2d;
}
&::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 4px;
&:hover {
background: #5a5a5a;
}
}
}
.terminal-line {
display: flex;
margin-bottom: 2px;
animation: terminal-line-appear 0.2s ease-out;
white-space: pre-wrap;
word-break: break-word;
@keyframes terminal-line-appear {
from {
opacity: 0;
transform: translateX(-4px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.terminal-timestamp {
color: #6a6a6a;
margin-right: $spacing-sm;
flex-shrink: 0;
font-size: 11px;
}
.terminal-text {
flex: 1;
}
/* Line type styles */
&.terminal-info {
.terminal-text {
color: #4fc3f7;
}
}
&.terminal-success {
.terminal-text {
color: #81c784;
font-weight: $font-weight-medium;
}
}
&.terminal-error {
.terminal-text {
color: #e57373;
font-weight: $font-weight-medium;
}
}
&.terminal-warning {
.terminal-text {
color: #ffb74d;
}
}
&.terminal-command {
.terminal-text {
color: #ba68c8;
font-weight: $font-weight-semibold;
}
}
&.terminal-output {
.terminal-text {
color: #d4d4d4;
opacity: 0.9;
}
}
}
.terminal-cursor {
display: inline-block;
color: #81c784;
margin-left: 4px;
animation: terminal-cursor-blink 1s step-end infinite;
}
@keyframes terminal-cursor-blink {
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0;
}
}
/* Responsive */
@media (max-width: 768px) {
.terminal-content {
font-size: 12px;
padding: $spacing-sm;
}
.terminal-line {
.terminal-timestamp {
display: none;
}
}
}
-93
View File
@@ -1,93 +0,0 @@
@use './assets/scss/variables' as *;
@use './assets/scss/mixins' as *;
@use './assets/scss/global' as *;
// Global root styles
:root {
line-height: 1.5;
font-weight: 400;
font-family: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
color-scheme: light dark;
color: $text-primary;
background-color: $bg-primary;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// Global element resets
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
// Global link styles
a {
font-weight: 500;
color: $primary;
text-decoration: none;
transition: color $transition-base;
&:hover {
color: $primary-light;
}
}
// Global heading styles
h1, h2, h3, h4, h5, h6 {
color: $text-primary;
margin: 0;
}
h1 {
font-size: $font-size-3xl;
line-height: 1.1;
}
h2 {
font-size: $font-size-2xl;
line-height: 1.2;
}
h3 {
font-size: $font-size-xl;
line-height: 1.3;
}
// Global button styles
button {
border-radius: $radius-md;
border: 1px solid transparent;
padding: $spacing-sm $spacing-lg;
font-size: $font-size-base;
font-weight: $font-weight-medium;
font-family: inherit;
background-color: $primary;
color: white;
cursor: pointer;
transition: all $transition-base;
&:hover {
background-color: $primary-light;
}
&:focus-visible {
outline: 2px solid $primary;
outline-offset: 2px;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.scss'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
-591
View File
@@ -1,591 +0,0 @@
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
@use "sass:color";
.app-detail-page {
@extend .page-wrapper;
}
.app-detail-content {
@extend .page-content;
}
// App header section
.app-header {
@include card;
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: $spacing-xl;
margin-bottom: $spacing-xl;
.app-title-section {
flex: 1;
min-width: 300px;
h1 {
margin: 0 0 $spacing-md 0;
font-size: $font-size-3xl;
color: $text-primary;
font-weight: $font-weight-bold;
}
.app-meta {
display: flex;
gap: $spacing-sm;
flex-wrap: wrap;
}
}
.app-actions {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
}
}
// Action buttons
.action-btn {
@include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm);
&.primary {
background: $primary;
color: white;
border-color: $primary-light;
&:hover:not(:disabled) {
background: $primary-light;
border-color: $primary-light;
}
}
&.service {
&:hover:not(:disabled) {
background: #3fff5c9d;
}
}
&.secondary {
background: $bg-secondary;
color: $text-primary;
border-color: $border-color;
}
&.danger {
background: $error;
color: white;
border-color: $error;
&:hover:not(:disabled) {
background: color.adjust($error, $lightness: -10%);
}
}
&.modify {
background: $warning;
color: white;
border-color: $warning;
white-space: nowrap;
&:hover:not(:disabled) {
background: color.adjust($warning, $lightness: -10%);
}
}
}
.back-button {
@extend .action-btn;
@extend .secondary;
}
// Content grid layout
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: $spacing-xl;
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
.backup-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
// Info cards
.info-card {
@include card;
margin-bottom: $spacing-xl;
&:last-child {
margin-bottom: 0;
}
h2 {
margin: 0 0 $spacing-lg 0;
font-size: $font-size-xl;
color: $text-primary;
font-weight: $font-weight-bold;
}
}
// service grid
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 2fr));
gap: $spacing-md;
}
.service-card {
@include card;
transition: transform $transition-base, box-shadow $transition-base;
position: relative;
overflow: hidden;
padding: $spacing-md;
.service-name {
h3 {
font-size: $font-size-lg;
color: $text-primary;
font-weight: $font-weight-bold;
}
}
.service-row {
flex-grow: 1;
flex: 1;
.service-value {
font-size: $font-size-sm;
display: -webkit-box;
}
}
&.dark-green {
background-color: #3fff5c;
}
&.green {
background-color: #3fff5c9d;
}
&.red {
background-color: #ff1313;
}
&.gray {
background-color: rgb(187, 187, 187)
}
}
// Info grid
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $spacing-lg;
}
.info-item {
display: flex;
flex-direction: column;
gap: $spacing-xs;
label {
font-size: $font-size-xs;
color: $text-secondary;
font-weight: $font-weight-medium;
text-transform: uppercase;
letter-spacing: 0.5px;
}
span {
font-size: $font-size-base;
color: $text-primary;
}
.no-value {
color: $text-muted;
font-style: italic;
}
.chaos-active {
background: color.adjust($info, $lightness: -10%);
font-weight: $font-weight-medium;
}
}
.domain-link {
color: $primary-light;
text-decoration: none;
font-size: $font-size-base;
transition: color $transition-base;
&:hover {
text-decoration: underline;
}
}
.server-link {
background: none;
border: none;
color: $primary-light;
cursor: pointer;
padding: 0;
font-size: $font-size-base;
text-align: left;
transition: color $transition-base;
&:hover {
background: none;
color: $primary-light;
text-decoration: underline;
}
}
.server-link {
background: none;
border: none;
color: $primary-dark;
cursor: pointer;
padding: 0;
font-size: $font-size-base;
text-align: left;
transition: color $transition-base;
&:hover {
color: $primary-light;
text-decoration: underline;
}
}
// Version information
.version-info {
display: flex;
flex-direction: column;
gap: $spacing-lg;
.version-current {
label {
display: block;
font-size: $font-size-xs;
color: $text-secondary;
font-weight: $font-weight-medium;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: $spacing-sm;
}
code {
display: inline-block;
background: $bg-secondary;
padding: $spacing-sm $spacing-md;
border-radius: $radius-sm;
font-size: $font-size-sm;
color: $text-primary;
font-family: monospace;
}
}
.version-upgrades {
label {
display: flex;
align-items: center;
gap: $spacing-sm;
font-size: $font-size-xs;
color: $text-secondary;
font-weight: $font-weight-medium;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: $spacing-md;
.upgrade-count {
background: $warning;
color: white;
padding: 2px 8px;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-bold;
}
}
.upgrade-list {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.upgrade-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
padding: $spacing-md;
background: $bg-secondary;
border-radius: $radius-md;
border: 2px solid transparent;
transition: border-color $transition-base;
&:hover {
border-color: $warning;
}
code {
flex: 1;
font-size: $font-size-sm;
color: $text-primary;
font-family: monospace;
}
.upgrade-btn {
padding: $spacing-xs $spacing-md;
background: $warning;
color: white;
border: none;
border-radius: $radius-sm;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-base;
&:hover:not(:disabled) {
background: color.adjust($warning, $lightness: -10%);
transform: translateY(-1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
}
.version-latest {
padding: $spacing-md;
background: rgba($success, 0.1);
background: color.adjust($success, $lightness: -10%);
border-radius: $radius-md;
text-align: center;
font-size: $font-size-base;
font-weight: $font-weight-medium;
}
}
.backup-info {
display: grid;
align-items: center;
text-align: left;
grid-template-columns: 2fr 1fr 2fr 100px;
gap: $spacing-md;
}
.backup-list {
max-height: 250px;
overflow-y: auto;
.create-btn {
width: 100%;
background: none;
color: $text-secondary;
border: 2px solid $text-muted;
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-base;
&:hover:not(:disabled) {
border-color: $warning;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.backup-item {
display: grid;
align-items: center;
text-align: left;
grid-template-columns: 2fr 1fr 2fr 100px;
gap: $spacing-md;
background: $bg-secondary;
border-radius: $radius-md;
border: 2px solid transparent;
transition: border-color $transition-base;
&:hover {
border-color: $warning;
}
code {
font-size: $font-size-sm;
color: $text-primary;
font-family: monospace;
}
.backup-btn {
background: none;
color: $text-secondary;
border: none;
padding: $spacing-sm $spacing-md;
border-radius: $radius-sm;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-base;
&.delete-btn {
color: $error;
}
&:hover:not(:disabled) {
transform: translateY(-1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
}
// Quick actions list
.action-list {
display: flex;
flex-direction: column;
gap: $spacing-sm;
.action-list-item {
display: flex;
align-items: center;
gap: $spacing-md;
padding: $spacing-md;
background: $bg-secondary;
border: 2px solid transparent;
border-radius: $radius-md;
cursor: pointer;
transition: all $transition-base;
font-size: $font-size-sm;
color: $text-primary;
text-align: left;
width: 100%;
&:hover:not(:disabled) {
background: white;
border-color: $primary;
transform: translateX(2px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.danger {
&:hover:not(:disabled) {
border-color: $error;
background: rgba($error, 0.05);
}
}
.action-text {
flex: 1;
font-weight: $font-weight-medium;
}
}
}
// Health status
.health-status {
display: flex;
flex-direction: column;
gap: $spacing-md;
.health-item {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: $spacing-md;
border-bottom: 1px solid $border-color;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
.health-label {
font-size: $font-size-sm;
color: $text-secondary;
}
.health-value {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-primary;
}
}
}
// editor modal overlay
// locks scrolling
body.modal-open {
overflow: hidden;
}
.editor-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
.secrets {
height: auto;
width: auto;
}
}
.editor-modal {
width: 90%;
height: 90%;
background: white;
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
.editor-header {
padding: 10px;
border-bottom: 1px solid #ddd;
}
.editor-body {
flex: 1;
overflow: auto; // 👈 THIS is key
display: flex;
// make your code editor stretch
> * {
flex: 1;
}
}
.secrets {
height: auto;
width: auto;
}
}
// navigation back to apps page
.back-navigation {
margin-bottom: 10px;
}
.rollback {
display: flex;
align-items: center;
gap: 8px;
}
.select-input {
padding: 8px 12px;
border: 1px solid #d0d7de;
border-radius: 6px;
background-color: #fff;
color: #24292f;
font-size: 14px;
}
-760
View File
@@ -1,760 +0,0 @@
import React, { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Header } from '../../components/Header/Header';
import { Loader } from '../../components/Loader/Loader';
import { Terminal } from '../../components/Terminal/Terminal';
import { EditorPage } from '../../components/Editor/Editor';
import { SecretsPage } from '../../components/Editor/Secrets';
import { apiService } from '../../services/api';
import { type AppSnapshot, type AbraApp, type AbraAppService, type AbraServiceState } from '../../types';
import type { LogEntry } from '../../services/mockApi';
import { createPortal } from "react-dom";
import { ArchiveRestore, Download, Trash2, SquarePlus } from "lucide-react";
import './App.scss';
export const AppDetail: React.FC = () => {
const { server, appName } = useParams<{ server: string; appName: string }>();
const navigate = useNavigate();
const [app, setApp] = useState<AbraApp | null>(null);
const [deployState, setDeployState] = useState("undeployed | deploying | deployed | failed");
const [serviceState, setServiceState] = useState<Record<string, AbraServiceState>>({});
const [services, setServices] = useState<AbraAppService[]>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [openEditor, setOpenEditor] = useState(false);
const [openSecrets, setOpenSecrets] = useState(false);
const [backupEnabled, setBackupEnabled] = useState(false);
const [backupSnapshots, setBackupSnapshots] = useState<AppSnapshot[]>();
const [rbVersion, setRbVersion] = useState("Auto");
// Terminal state
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
const [terminalActive, setTerminalActive] = useState(false);
// Stream state
const stopRef = useRef<null | (() => void)>(null);
const deployRef = useRef<null | (() => void)>(null);
// App Information
const [versions, setVersions] = useState<string[]>([]);
// Use to refresh page
const [refreshKey, setRefreshKey] = useState(0);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
const getServiceClass = (state: string, status: string) => {
if (state === "running" && status.includes("\(healthy\)")) return "service-card dark-green";
if (status.includes("unhealthy")) return "service-card red"
if (state === "running") return "service-card green";
return "service-card gray";
};
const getServiceClassDeploying = (state: string, status: string) => {
if (state.includes("converged") && status.includes("\(healthy\)")) return "service-card dark-green";
if (status.includes("unhealthy")) return "service-card red"
if (state.includes("converged")) return "service-card green";
return "service-card gray";
};
useEffect(() => {
const fetchApp = async () => {
try {
if (isMockMode) {
const { mockApiService } = await import('../../services/mockApi');
const appsData = await mockApiService.getAppsGrouped();
const serverApps = appsData[server || ''];
const foundApp = serverApps?.apps.find(a => a.appName === appName);
if (foundApp) {
setApp(foundApp);
} else {
setError('App not found');
}
} else {
console.log('fetching app...');
const appsData = await apiService.getAppsGrouped();
if (appsData[server || ''].backupEnabled) {
setBackupEnabled(true);
}
const serverApps = appsData[server || ''];
const foundApp = serverApps?.apps.find(a => a.appName === appName);
if (foundApp) {
setApp(foundApp);
const versionData = await apiService.getAppVersions(foundApp.appName);
if (versionData) {
setVersions(versionData);
}
// when the app is deploying it should handle setting the deploy state itself after success/failure.
if (deployState !== "deploying") {
setDeployState(foundApp.status === "deployed" ? "deployed" : "undeployed");
}
} else {
setError('App not found');
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load app');
} finally {
setLoading(false);
}
};
fetchApp();
}, [server, appName, isMockMode, refreshKey]);
// checks status of app containers
useEffect(() => {
let isMounted = true;
const heartbeat = async () => {
while (isMounted) {
if (deployState === "deployed" && appName) {
const services = await apiService.getServices(appName);
if (services) {
setServices(services);
} else {
setServices([]);
}
}
await new Promise(resolve => setTimeout(resolve, 10000));
}
};
heartbeat();
return () => {
isMounted = false;
};
}, [deployState, appName]);
useEffect(() => {
const fetchSnapshots = async () => {
if (backupEnabled && app) {
console.log('test');
// grab backupSnapshots here
const snapshots = await apiService.getBackupList(app.appName);
setBackupSnapshots(snapshots);
console.log(snapshots);
}
}
fetchSnapshots();
}, [backupEnabled])
function EditorModal({ onClose }) {
return createPortal(
<div className="editor-overlay" onClick={onClose}>
<div
className="editor-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="editor-header">
<button onClick={onClose}>Close</button>
</div>
<div className="editor-body">
<EditorPage
appName={app ? app.appName : ""}
/>
</div>
</div>
</div>,
document.body
);
}
function SecretsModal({ onClose }) {
return createPortal(
<div className="editor-overlay secrets" onClick={onClose}>
<div
className="editor-modal secrets"
onClick={(e) => e.stopPropagation()}
>
<div className="editor-header">
<button onClick={onClose}>Close</button>
</div>
<div className="editor-body">
<SecretsPage
appName={app ? app.appName : ""}
/>
</div>
</div>
</div>,
document.body
);
}
const waitForLogs = (appName: string) => {
setDeployState("deploying");
console.log("deploying");
deployRef.current = apiService.deployLogs(appName,
(update) => {
if (update.type === "service") {
console.log(update.data.name)
const serviceName = update.data.name.slice(appName.length+1)
setServiceState(prev => ({
...prev,
[serviceName]: {
...prev[serviceName] ?? {},
...update.data
}
}))
}
if (update.type === "done") {
if (update.data.failed) {
setDeployState('failed');
} else {
setDeployState("deployed");
}
setRefreshKey(prev => prev + 1);
}
}
)
}
// TODO
const handleDeleteSnapshot = async () => {}
const handleDownloadSnapshot = async (appName: string) => {
const a = document.createElement('a')
a.href = `http://localhost:3000/api/apps/${appName}/backup/download`
a.click()
// console.log("downloading...")
// const blob = await apiService.downloadSnapshot(appName)
// const url = URL.createObjectURL(blob)
// const a = document.createElement('a')
// a.href = url
// a.download = `${appName}.tar.gz`
// a.click()
// URL.revokeObjectURL(url)
}
// RESTORE UNTESTED
const handleRestoreSnapshot = async (appName: string, snapshot?: string) => {
apiService.restoreBackup(appName, snapshot)
}
const handleAction = async (action: string, version?: string) => {
if (!app) return;
setActionLoading(action);
try {
if (isMockMode) {
const { mockApiService } = await import('../../services/mockApi');
const onLog = (log: LogEntry) => {
setTerminalLogs(prev => [...prev, log]);
};
switch (action) {
case 'deploy':
await mockApiService.deployApp(app.appName, onLog);
break;
case 'stop':
await mockApiService.stopApp(app.appName, onLog);
break;
case 'upgrade':
if (version) {
await mockApiService.upgradeApp(app.appName, version, onLog);
}
break;
case 'remove':
await mockApiService.removeApp(app.appName, onLog);
break;
}
} else {
// Real API calls
switch (action) {
case 'stop':
await apiService.stopApp(app.appName);
setRefreshKey(prev => prev + 1);
break;
case 'upgrade':
await apiService.upgradeApp(app.appName);
waitForLogs(app.appName);
break;
case 'deploy':
await apiService.deployApp(app.appName);
waitForLogs(app.appName);
break;
case 'logs':
setTerminalActive(true);
setTerminalLogs([]);
if (version) {
stopRef.current = apiService.getLogs(app.appName, version,
(line) => {
setTerminalLogs(prev => [...prev, {
type: 'info',
text: `${line}`,
timestamp: new Date()
}]);
}
)
}
break;
case 'remove':
await apiService.removeApp(app.appName);
setApp(null);
break;
case 'config':
setOpenEditor(true);
break;
case 'secrets':
setOpenSecrets(true);
break;
case 'rollback':
if (version) {
console.log(version);
apiService.rollbackApp(app.appName, version);
waitForLogs(app.appName);
} else {
console.log("no version for rollback");
}
}
}
} catch (err) {
console.error('Action failed:', err);
setTerminalLogs(prev => [...prev, {
type: 'error',
text: `❌ Error: ${err instanceof Error ? err.message : 'Action failed'}`,
timestamp: new Date()
}]);
} finally {
setActionLoading(null);
}
};
if (loading) {
return (
<div className="app-detail-page">
<Header />
<main className="app-detail-content">
<div className="loading">
<Loader />
<div>Loading application</div>
</div>
</main>
</div>
);
}
if (error || !app) {
return (
<div className="app-detail-page">
<Header />
<main className="app-detail-content">
<div className="error">{error || 'App not found'}</div>
<button onClick={() => navigate('/apps')} className="back-button">
Back to Apps
</button>
</main>
</div>
);
}
// TODO: make sure this makes sense when app.upgrade is unknown
const upgradeVersions = (app.upgrade !== 'latest' && app.upgrade !== 'unknown') ? app.upgrade.split('\n') : [];
return (
<div className="app-detail-page">
{openEditor && (
<EditorModal onClose={() => setOpenEditor(false)} />
)}
{openSecrets && (
<SecretsModal onClose={() => setOpenSecrets(false)} />
)}
<Header />
<main className="app-detail-content">
<div className="back-navigation">
<button
onClick={() => navigate(`/apps`)}
className="server-link"
>
← Back to Apps
</button>
</div>
<div className="app-header">
<div className="app-title-section">
<h1>{app.appName}</h1>
<div className="app-meta">
<span className="recipe-badge">{app.recipe}</span>
<span className={`status-badge status-${app.status}`}>{app.status}</span>
{app.chaos === 'true' && (
<span className="chaos-badge" title="Chaos mode enabled"> Chaos</span>
)}
</div>
</div>
<div className="app-actions">
<button
className="action-btn danger"
onClick={() => handleAction('stop')}
disabled={!!actionLoading || deployState === "undeployed"}
>
{actionLoading === 'stop' ? 'Stopping...' : 'Stop'}
</button>
<button
className="action-btn"
onClick={() => handleAction('deploy')}
disabled={!!actionLoading || deployState === "deployed"}
>
{actionLoading === 'deploy' ? 'Deploying...' : 'Deploy'}
</button>
</div>
</div>
{/* Terminal Component */}
<Terminal
logs={terminalLogs}
isActive={terminalActive}
onClose={() => {
stopRef.current?.();
stopRef.current = null;
setTerminalActive(false)
}}
/>
<div className="content-grid">
{/* Left Column - Main Info */}
<div className="main-column">
<section className="info-card">
<h2>Application Details</h2>
<div className="info-grid">
<div className="info-item">
<label>App Name</label>
<span>{app.appName}</span>
</div>
<div className="info-item">
<label>Domain</label>
{app.domain ? (
<a href={`https://${app.domain}`} target="_blank" rel="noopener noreferrer" className="link">
{app.domain} ↗
</a>
) : (
<span className="no-value">No domain configured</span>
)}
</div>
<div className="info-item">
<label htmlFor="server-link">Server</label>
<Link
id="server-link"
to={`/servers/${app.server}`}
className="link"
>
{app.server} ↗
</Link>
</div>
<div className="info-item">
<label>Recipe</label>
<span>{app.recipe}</span>
</div>
<div className="info-item">
<label>Status</label>
<span className={`status-badge status-${app.status}`}>
{app.status}
</span>
</div>
<div className="info-item">
<label>Chaos Mode</label>
<span className={app.chaos === 'true' ? 'chaos-active' : ''}>
{app.chaos === 'true' ? '☠️ Enabled' : 'Disabled'}
</span>
</div>
</div>
</section>
{(deployState === "deployed" || deployState === "failed") && (
<section className="info-card">
<h2> Service Information </h2>
<div className="service-grid">
{services === undefined || services?.length === 0 ? (
<div className="no-services"> Loading services... </div>
) : (
services.map((service) => (
<div
key={service.service}
className={getServiceClass(service.state, service.status)}
>
<div className="service-name">
<h3> {service.service} </h3>
</div>
<div className="service-row">
<span className="service-value">{service.state}</span>
<span className="service-value">{service.status}</span>
</div>
<button
className="action-btn"
onClick={() => handleAction('logs', service.service)}
disabled={!!actionLoading}
>
Logs
</button>
</div>
))
)}
</div>
</section>)}
{(deployState === "deploying") && (
<section className="info-card">
<h2> Service Information </h2>
<div className="service-grid">
{serviceState === undefined || Object.keys(serviceState).length === 0 ? (
<div className="no-services"> Preparing services... </div>
) : (
Object.keys(serviceState).map((name) => (
<div
key={name}
className={getServiceClass(serviceState[name].status, serviceState[name].health)}
>
<div className="service-name">
<h3> {name} </h3>
</div>
<div className="service-row">
<span className="service-value">{serviceState[name].status}</span>
<span className="service-value">{serviceState[name].health}</span>
</div>
<button
className="action-btn"
onClick={() => handleAction('logs', appName)}
disabled={!!actionLoading}
>
Logs
</button>
</div>
))
)}
</div>
</section>)}
<section className="info-card">
<h2>Version Information</h2>
<div className="version-info">
<div className="version-current">
<label>Current Version</label>
<code>{app.version}</code>
</div>
{app.chaosVersion !== 'unknown' && (
<div className="version-current">
<label>Chaos Version</label>
<code>{app.chaosVersion}</code>
</div>
)}
{upgradeVersions.length > 0 && (
<div className="version-upgrades">
<label>
Available Upgrades
<span className="upgrade-count">{upgradeVersions.length}</span>
</label>
<div className="upgrade-list">
{upgradeVersions.map((version, idx) => (
<div key={idx} className="upgrade-item">
<code>{version}</code>
<button
className="upgrade-btn"
onClick={() => handleAction('upgrade', version)}
disabled={!!actionLoading}
>
{actionLoading === 'upgrade' ? 'Upgrading...' : 'Upgrade'}
</button>
</div>
))}
</div>
</div>
)}
{app.upgrade === 'unknown' && (
<div className="version-upgrades">
<label>
Available Upgrades
</label>
<div className="upgrade-list">
<div className="upgrade-item">
<code>None</code>
</div>
</div>
</div>
)}
{app.upgrade === 'latest' && (
<div className="version-latest">
✓ Running latest version
</div>
)}
<div className="rollback">
<button
className="action-btn modify"
onClick={() => {
if (confirm(`Are you sure you want to rollback ${app.appName} to version ${rbVersion}?\nThis could be destructive, please ensure you have a backup beforehand`)) {
handleAction('rollback', rbVersion);
}
}}
>
Rollback To
</button>
<select
className="select-input"
name="version"
value={rbVersion || ""}
onChange={(e) => setRbVersion(e.target.value)}
>
<option value="">Auto</option>
{versions.map((version) => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
</div>
</div>
</section>
<section className="info-card">
{backupEnabled && (
<div>
<div className="backup-header">
<h2> Backups </h2>
<span className={`status-badge status-deployed`}>
backupbot deployed
</span>
</div>
<div className="backup-info">
<span>Time Created</span>
<span>Snapshot ID</span>
<span>Version</span>
<span></span>
</div>
<div className="backup-list">
<button
className="create-btn"
onClick={() => {
apiService.createBackup(app.appName)
}}
aria-label="create new backup"
title="create new backup"
>
<SquarePlus size={20} />
</button>
{backupSnapshots?.map((snapshot, idx) => (
<div key={snapshot.createdAt} className="backup-item">
<span> {snapshot.createdAt} </span>
<span> {snapshot.snapshot} </span>
<code>{snapshot.version}</code>
<div className="backup-actions">
<button
className="backup-btn"
onClick={() => handleRestoreSnapshot(app.appName, snapshot.snapshot)}
aria-label="Restore backup"
title="Restore backup"
>
<ArchiveRestore size={18} />
</button>
<button
className="backup-btn"
onClick={() => handleDownloadSnapshot(app.appName)}
aria-label="Download backup"
title="Download backup"
>
<Download size={18} />
</button>
{ /* Not convinced this is necesary...
<button
className="backup-btn delete-btn"
onClick={() => handleDeleteSnapshot()}
aria-label="Delete backup"
title="Delete backup"
>
<Trash2 size={18} />
</button> */ }
</div>
</div>
))}
</div>
</div>
)}
</section>
</div>
{/* Right Column - Actions & Stats */}
<div className="sidebar-column">
<section className="info-card">
<h2>Quick Actions</h2>
<div className="action-list">
<button
className="action-list-item"
onClick={() => handleAction('deploy')}
disabled={!!actionLoading}
>
<span className="action-text">Deploy Application</span>
</button>
{ /* TODO: make upgrade have force option*/}
<button
className="action-list-item"
onClick={() => handleAction('upgrade')}
disabled={!!actionLoading}
>
<span className="action-text">Upgrade Application</span>
</button>
<button
className="action-list-item"
onClick={() => handleAction('stop')}
disabled={!!actionLoading}
>
<span className="action-text">Stop Application</span>
</button>
<button
className="action-list-item danger"
onClick={() => {
if (confirm(`Are you sure you want to remove ${app.appName}?\nThis will delete all data and config`)) {
handleAction('remove');
}
}}
disabled={!!actionLoading}
>
<span className="action-text">Remove Application</span>
</button>
<button
className="action-list-item"
onClick={() => handleAction('config')}
disabled={!!actionLoading}
>
<span className="action-text">Manage Config</span>
</button>
<button
className="action-list-item"
onClick={() => handleAction('secrets')}
disabled={!!actionLoading}
>
<span className="action-text">Manage Secrets</span>
</button>
</div>
</section>
<section className="info-card">
<h2>Health Status</h2>
<div className="health-status">
<div className="health-item">
<span className="health-label">CPU Usage</span>
<span className="health-value">—</span>
</div>
<div className="health-item">
<span className="health-label">Memory</span>
<span className="health-value">—</span>
</div>
<div className="health-item">
<span className="health-label">Uptime</span>
<span className="health-value">—</span>
</div>
</div>
</section>
</div>
</div>
</main>
</div>
);
};
-167
View File
@@ -1,167 +0,0 @@
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
// Extend global page wrapper
.apps-page {
@extend .page-wrapper;
}
.apps-content {
@extend .page-content;
}
// Compact stats row
.stats-row {
display: flex;
align-items: center;
gap: $spacing-md;
margin-bottom: $spacing-xl;
flex-wrap: wrap;
}
.stat-chip {
@include chip();
&.active {
border-color: $primary-dark;
background: $bg-primary;
box-shadow: 0 0 0 3px rgba($primary-dark, 0.08);
}
.stat-label {
color: $text-secondary;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
}
.stat-value {
color: $text-primary;
font-size: $font-size-xl;
font-weight: $font-weight-bold;
min-width: 24px;
text-align: center;
}
}
/* reset-filters-btn removed — outline on stat-chip indicates active filters */
// Apps table specific styles
.apps-table-container {
@include scrollable-card;
margin-bottom: $spacing-lg;
}
.apps-table {
width: 100%;
border-collapse: collapse;
thead {
background-color: $bg-tertiary;
th {
padding: $spacing-md $spacing-lg;
text-align: left;
font-weight: $font-weight-semibold;
color: $text-primary;
font-size: $font-size-sm;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid $border-color;
}
}
tbody {
tr {
border-bottom: 1px solid $border-color;
transition: background-color $transition-base;
&:hover {
background-color: rgba($primary, 0.05);
}
&:last-child {
border-bottom: none;
}
}
td {
padding: $spacing-md $spacing-lg;
font-size: $font-size-base;
color: $text-primary;
&.no-results {
text-align: center;
padding: $spacing-3xl;
color: $text-secondary;
}
}
}
}
// App table cell styles
.app-name-cell {
.app-name {
font-weight: $font-weight-medium;
color: $text-primary;
}
.domain-link {
color: $primary-dark;
text-decoration: none;
transition: color $transition-base;
&:hover {
color: $primary-light;
text-decoration: underline;
}
}
}
.no-domain {
color: $text-muted;
font-style: italic;
}
.version-cell {
display: flex;
align-items: center;
gap: $spacing-sm;
.version {
font-family: monospace;
font-size: $font-size-sm;
color: $text-primary;
}
.chaos-badge,
.upgrade-available {
font-size: $font-size-base;
}
}
// Action buttons
.actions {
display: flex;
gap: $spacing-sm;
.action-btn {
@include action-btn(1px, $radius-sm, $spacing-xs $spacing-md, $font-size-sm);
&:hover {
background-color: $primary;
color: white;
border-color: $primary;
}
&.upgrade {
border-color: $warning;
color: $warning;
&:hover {
background-color: $warning;
color: white;
}
}
}
}
-289
View File
@@ -1,289 +0,0 @@
// TODOS:
// make the two filters non-exlusive
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Header } from '../../components/Header/Header';
import { Loader } from '../../components/Loader/Loader';
import { apiService } from '../../services/api';
import type { AbraApp, AppWithServer, ServerAppsResponse } from '../../types';
import './Apps.scss';
export const Apps: React.FC = () => {
const navigate = useNavigate();
const [appsData, setAppsData] = useState<ServerAppsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [filterServer, setFilterServer] = useState<string>('all');
const [showUpgradesOnly, setShowUpgradesOnly] = useState(false);
const [showChaosOnly, setShowChaosOnly] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
useEffect(() => {
const fetchData = async () => {
try {
if (isMockMode) {
const { mockApiService } = await import('../../services/mockApi');
const data = await mockApiService.getAppsGrouped();
setAppsData(data);
} else {
const data = await apiService.getAppsGrouped();
setAppsData(data);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load apps');
} finally {
setLoading(false);
}
};
fetchData();
}, [isMockMode]);
// Flatten and enrich apps data
const allApps: AppWithServer[] = useMemo(() => {
if (!appsData) return [];
return Object.entries(appsData).flatMap(([serverName, serverData]) =>
serverData.apps.map(app => ({
...app,
serverStats: {
appCount: serverData.appCount,
upgradeCount: serverData.upgradeCount,
},
}))
);
}, [appsData]);
// Get unique servers
const servers = useMemo(() => {
if (!appsData) return [];
return Object.keys(appsData);
}, [appsData]);
// Filter apps (additive filters: upgrades AND chaos can be applied together)
const filteredApps = useMemo(() => {
return allApps.filter(app => {
const matchesSearch =
(app.appName || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(app.recipe || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(app.domain || '').toLowerCase().includes(searchTerm.toLowerCase());
const matchesServer = filterServer === 'all' || app.server === filterServer;
const matchesChaos = !showChaosOnly || app.chaos === 'true';
const matchesUpgrade = !showUpgradesOnly || app.upgrade !== 'latest';
return matchesSearch && matchesServer && matchesChaos && matchesUpgrade;
});
}, [allApps, searchTerm, filterServer, showUpgradesOnly, showChaosOnly]);
const stats = useMemo(() => {
const total = allApps.length;
const needsUpgrade = allApps.filter(app => app.upgrade !== 'latest').length;
const chaosApps = allApps.filter(app => app.chaos === 'true').length;
const totalServers = servers.length;
return { total, needsUpgrade, chaosApps, totalServers };
}, [allApps, servers]);
const toggleUpgrades = () => setShowUpgradesOnly(prev => !prev);
const toggleChaos = () => setShowChaosOnly(prev => !prev);
if (loading) {
return (
<div className="apps-page">
<Header />
<main className="apps-content">
<div className="loading">
<Loader />
<div>Loading apps</div>
</div>
</main>
</div>
);
}
if (error) {
return (
<div className="apps-page">
<Header />
<main className="apps-content">
<div className="error">Error: {error}</div>
</main>
</div>
);
}
return (
<div className="apps-page">
<Header />
<main className="apps-content">
<div className="page-header">
<h1>Applications</h1>
<p className="subtitle">{stats.total} apps across {stats.totalServers} servers</p>
</div>
{/* Compact Stats Overview */}
<div className="stats-row">
<button
className="stat-chip"
onClick={() => navigate('/servers')}
title="View servers"
>
<span className="stat-label">Servers</span>
<span className="stat-value">{stats.totalServers}</span>
</button>
<button
className={`stat-chip filter-chip ${showUpgradesOnly ? 'active' : ''}`}
onClick={toggleUpgrades}
title="Click to toggle apps with upgrades available"
disabled={stats.needsUpgrade === 0}
>
<span className="stat-label">Upgrades</span>
<span className="stat-value">{stats.needsUpgrade}</span>
</button>
<button
className={`stat-chip filter-chip ${showChaosOnly ? 'active' : ''}`}
onClick={toggleChaos}
title="Click to toggle chaos mode apps"
disabled={stats.chaosApps === 0}
>
<span className="stat-label">Chaos</span>
<span className="stat-value">{stats.chaosApps}</span>
</button>
</div>
{/* Filters */}
<div className="filters">
<input
type="text"
placeholder="Search apps by name, recipe, or domain..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
<select value={filterServer} onChange={(e) => setFilterServer(e.target.value)}>
<option value="all">All Servers</option>
{servers.map(server => (
<option key={server} value={server}>{server}</option>
))}
</select>
</div>
{/* Apps Table */}
<div className="apps-table-container">
<table className="apps-table">
<thead>
<tr>
<th>App Name</th>
<th>Recipe</th>
<th>Domain</th>
<th>Server</th>
<th>Version</th>
<th>Status</th>
{/* <th>Actions</th> */}
</tr>
</thead>
<tbody>
{filteredApps.length === 0 ? (
<tr>
<td colSpan={7} className="no-results">
No apps found matching your filters
</td>
</tr>
) : (
filteredApps.map((app) => (
<tr
key={`${app.server}/${app.appName}`}
onClick={() => navigate(`/apps/${app.server}/${app.appName}`)}
style={{ cursor: 'pointer' }}
>
<td className="app-name-cell">
<span className="app-name">{app.appName}</span>
</td>
<td>
<span className="recipe-badge">{app.recipe}</span>
</td>
<td>
{app.domain ? (
<a
href={`https://${app.domain}`}
target="_blank"
rel="noopener noreferrer"
className="domain-link"
onClick={(e) => e.stopPropagation()}
>
{app.domain}
</a>
) : (
<span className="no-domain">—</span>
)}
</td>
<td>
<span className="server-badge">{app.server}</span>
</td>
<td>
<div className="version-cell">
<span className="version">{app.version}</span>
{app.chaos === 'true' && (
<span className="chaos-badge" title="Chaos mode enabled"></span>
)}
{app.upgrade !== 'latest' && (
<span className="upgrade-available" title="Upgrade available">
</span>
)}
</div>
</td>
<td>
<span className={`status-badge status-${app.status}`}>
{app.status}
</span>
</td>
{/* <td>
<div className="actions">
<button
className="action-btn"
title="View details"
onClick={(e) => {
e.stopPropagation();
navigate(`/apps/${app.server}/${app.appName}`);
}}
>
details
</button>
{app.upgrade !== 'latest' && (
<button
className="action-btn upgrade"
title="Upgrade"
onClick={(e) => {
e.stopPropagation();
// Handle upgrade action
}}
>
upgrade
</button>
)}
</div>
</td> */}
</tr>
))
)}
</tbody>
</table>
</div>
<div className="results-count">
Showing {filteredApps.length} of {allApps.length} apps
</div>
</main>
</div>
);
};
-136
View File
@@ -1,136 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Header } from '../../components/Header/Header';
import { Loader } from '../../components/Loader/Loader';
import { useNavigate } from 'react-router-dom';
import { apiService } from '../../services/api';
import type { AbraApp, AbraServer } from '../../types';
import './_Dashboard.scss';
export const Dashboard: React.FC = () => {
const [apps, setApps] = useState<AbraApp[]>([]);
const [servers, setServers] = useState<AbraServer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const navigate = useNavigate();
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
useEffect(() => {
const fetchData = async () => {
try {
if (isMockMode) {
// Use mock API in development
const { mockApiService } = await import('../../services/mockApi');
const [appsData, serversData] = await Promise.all([
mockApiService.getAppsGrouped(),
mockApiService.getServers(),
]);
// Flatten the grouped apps data
const flatApps = Object.values(appsData).flatMap(serverData => serverData.apps);
setApps(flatApps);
setServers(serversData);
} else {
// Use real API in production
const [appsData, serversData] = await Promise.all([
apiService.getAppsGrouped(),
apiService.getServers(),
]);
// Flatten the grouped apps data
const flatApps = Object.values(appsData).flatMap(serverData => serverData.apps);
setApps(flatApps);
setServers(serversData);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
};
fetchData();
}, [isMockMode]);
// Calculate stats
const deployedAppsCount = apps.filter(a => a.status === 'deployed').length;
const serversWithAppsCount = new Set(apps.map(a => a.server)).size;
if (loading) {
return (
<div className="dashboard-page">
<Header />
<main className="dashboard-content">
<div className="loading">
<Loader />
<div>Loading dashboard</div>
</div>
</main>
</div>
);
}
if (error) {
return (
<div className="dashboard-page">
<Header />
<main className="dashboard-content">
<div className="error">Error: {error}</div>
</main>
</div>
);
}
return (
<div className="dashboard-page">
<Header />
<main className="dashboard-content">
<div className="page-header">
<h1>Dashboard</h1>
</div>
<div className="stats-grid">
<button onClick={() => navigate('/apps')} className="nav-link bland-button">
<div className="stat-card">
<h3>Apps</h3>
<p className="stat-label">
{deployedAppsCount} deployed
</p>
</div>
</button>
<button onClick={() => navigate('/servers')} className="nav-link bland-button">
<div className="stat-card">
<h3>Servers</h3>
<p className="stat-label">
{serversWithAppsCount} connected
</p>
</div>
</button>
</div>
<section className="recent-apps">
<h3>Recent Applications</h3>
<div className="apps-list">
{apps.slice(0, 5).map((app, index) => (
<div
key={`${app.server}-${app.appName}-${index}`}
className="app-item"
onClick={() => navigate(`/apps/${app.server}/${app.appName}`)}
>
<div className="app-info">
<h4>{app.appName}</h4>
<p className="app-domain">{app.domain || 'No domain'}</p>
<p className="app-server">{app.server}</p>
</div>
<span className={`status-badge status-${app.status}`}>
{app.status}
</span>
</div>
))}
</div>
</section>
</main>
</div>
);
};
-59
View File
@@ -1,59 +0,0 @@
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
// Extend global page wrapper
.dashboard-page {
@extend .page-wrapper;
}
.dashboard-content {
@extend .page-content;
}
// Dashboard-specific styles
.recent-apps {
margin-bottom: $spacing-2xl;
h3 {
font-size: $font-size-2xl;
margin: 0 0 $spacing-lg;
color: $text-primary;
}
}
.apps-list {
@include card-list-vertical;
}
.app-item {
@include card;
@include card-hover-lift(-2px, $shadow-lg);
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
.app-info {
flex: 1;
h4 {
font-size: $font-size-lg;
color: $text-primary;
font-weight: $font-weight-semibold;
}
.app-domain {
margin: 0 0 $spacing-xs;
color: $text-muted;
font-size: $font-size-sm;
}
.app-server {
margin: 0;
font-size: $font-size-sm;
color: $text-secondary;
font-family: monospace;
}
}
}
View File
View File
-159
View File
@@ -1,159 +0,0 @@
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
.recipe-form {
@include card;
max-width: 680px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: $spacing-lg;
padding: $spacing-lg;
h2 {
margin: 0;
font-size: $font-size-xl;
color: $text-primary;
}
.field {
display: flex;
flex-direction: column;
gap: $spacing-xs;
&.field-inline {
flex-direction: row;
align-items: center;
gap: $spacing-md;
}
label {
color: $text-secondary;
font-size: $font-size-sm;
display: flex;
flex-direction: column;
gap: $spacing-xs;
input[type="text"],
input[type="email"],
input:not([type]),
select {
padding: $spacing-sm $spacing-md;
border: 2px solid $border-color;
border-radius: $radius-md;
font-size: $font-size-base;
background: $bg-primary;
color: $text-primary;
transition: border-color $transition-base, box-shadow $transition-base;
}
input:focus,
select:focus {
outline: none;
border-color: $primary;
box-shadow: 0 0 0 4px rgba($primary, 0.06);
}
}
}
.form-actions {
display: flex;
gap: $spacing-sm;
justify-content: flex-end;
.action-btn {
@include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm);
}
}
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
font-size: $font-size-base;
color: $text-primary;
input[type="checkbox"] {
width: 18px;
height: 18px;
}
}
.form-subtitle {
margin: 0;
color: $text-secondary;
font-size: $font-size-sm;
}
.form-error {
background: rgba($error, 0.08);
color: $error;
padding: $spacing-sm $spacing-md;
border-radius: $radius-sm;
}
.select-input {
width: 100%;
}
.modal {
background: white;
position: relative;
padding: 24px;
border-radius: 8px;
width: min(100%, 800px);
max-height: 90vh;
}
.modal .close-btn {
position: absolute;
top: 10px;
right: 10px;
cursor: pointer;
font-size: 20px;
line-height: 1;
padding: 4px 8px;
border-radius: 4px;
background: rgba(255, 0, 0, 0.308);
}
.modal .close-btn:hover {
background: rgba(255, 0, 0, 0.797);
}
.modal .close-btn:focus {
outline: 2px solid #888;
}
.secrets table {
width: 90%;
border-collapse: collapse;
margin-top: 10px;
}
.secrets th,
.secrets td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.secrets th {
background-color: #f5f5f5;
font-weight: bold;
}
.secrets tr:nth-child(even) {
background-color: #fafafa;
}
.copy-btn {
margin-left: 10px;
padding: 2px 6px;
font-size: 12px;
cursor: pointer;
}
-158
View File
@@ -1,158 +0,0 @@
import { useState, useEffect } from "react";
import { apiService } from '../../services/api';
import type { AbraServer, AbraAppSecret } from '../../types';
import './RecipeForm.scss';
function RecipeForm({ recipe, onClose }) {
const [loading, setLoading] = useState(true);
const [submitted, setSubmitted] = useState(false);
const [servers, setServers] = useState<AbraServer[]>([]);
const [secretData, setSecretData] = useState<AbraAppSecret[]>([]);
const [error, setError] = useState('');
useEffect(() => {
const fetchData = async () => {
try {
if (servers.length === 0) {
const [serversData] = await Promise.all([
apiService.getServers(),
]);
setServers(serversData);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load servers');
} finally {
setLoading(false);
}
};
fetchData();
});
const [formData, setFormData] = useState({
server: "",
domain: "",
chaos: false,
secrets: false,
});
const handleChange = (e) => {
const {name, type, value, checked} = e.target;
setFormData((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
console.log(formData);
};
const handleSubmit = async (e) => {
e.preventDefault();
console.log("Submitting:", formData);
setSubmitted(true);
const generatedSecrets = await apiService.newApp(recipe.name, formData);
setSecretData(generatedSecrets);
};
return (
<div className="modal">
<button className="close-btn" onClick={onClose}>x</button>
{(secretData.length === 0 && submitted) && (
<p> Creating new app...</p>
)
}
{(secretData.length > 0 && submitted) && (
<div className="secrets">
<table>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{secretData.map((secret) => (
<tr key={secret.name}>
<td>{secret.name}</td>
<td>
<span>{secret.value}</span>
<button
className="copy-btn"
onClick={() => navigator.clipboard.writeText(secret.value)}
>
Copy
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{(submitted === false) &&
<form className="recipe-form" onSubmit={handleSubmit}>
<h2>{recipe.name}</h2>
<p className="form-subtitle"> Configure and deploy this recipe.</p>
{ error && <div className="form-error">{error}</div>}
{ loading ? (<p> Loading servers...</p>
) : (
<div className="field">
<label>
Choose a server to deploy to:
<select
className="select-input"
name="server"
value={formData.server || ""}
onChange={handleChange}
>
<option value="">None</option>
{servers.map((server) => (
<option key={server.name} value={server.name}>
{server.name}
</option>
))}
</select>
</label>
</div>
)}
<div className="field">
<label>
Domain:
<input
name="domain"
value={formData.domain}
onChange={handleChange}
/>
</label>
</div>
<div>
<label className="checkbox-label">
<input
type="checkbox"
name="chaos"
onChange={handleChange}
/>
Chaos Mode
</label>
</div>
<div>
<label className="checkbox-label">
<input
type="checkbox"
name="secrets"
onChange={handleChange}
/>
Autogenerate Secrets
</label>
</div>
<button type="submit">Submit</button>
</form>
}
</div>
);
}
export default RecipeForm;
-154
View File
@@ -1,154 +0,0 @@
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
// Extend global page wrapper
.recipes-page {
@extend .page-wrapper;
}
.recipes-content {
@extend .page-content;
}
// Recipes grid
.recipes-grid {
display: grid;
justify-content: stretch;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: $spacing-xl;
margin-bottom: $spacing-xl;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
// Recipe card
.recipe-card {
@include card;
@include card-dimensions(320px, 180px);
row-gap: 1em;
grid-template-rows: 1fr 2fr auto 0.9fr;
transition: transform $transition-base, box-shadow $transition-base;
position: relative;
overflow: hidden;
max-width: 30em;
.recipe-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
border-bottom: 2px solid $bg-secondary;
.recipe-title {
h3 {
margin: 0 0 $spacing-xs;
font-size: $font-size-xl;
color: $text-primary;
font-weight: $font-weight-bold;
}
.recipe-host {
font-size: $font-size-sm;
color: $text-muted;
font-family: monospace;
}
}
img {
width: clamp(20px, 3vw, 32px);
height: clamp(20px, 3vw, 32px);
object-fit: contain;
}
}
.card-tags {
display: flex;
gap: 6px;
margin-top: $spacing-sm;
height: $spacing-md;
flex-wrap: wrap;
}
.tag-category {
font-size: 12px;
padding: 2px 8px;
border-radius: 5px;
color: rgb(255, 255, 255);
font-weight: 700;
background-color: rgb(111, 128, 255);
}
.tag-feature {
font-size: 12px;
padding: 2px 8px;
border-radius: 5px;
color: rgb(255, 255, 255);
font-weight: 700;
background-color: rgb(22, 180, 22);
}
.recipe-stats {
flex-grow: 1;
flex: 1;
margin-bottom: $spacing-lg;
.stat-row {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
}
.recipe-actions {
display: flex;
gap: $spacing-sm;
.action-btn {
flex: 1;
@include action-btn(2px, $radius-md, $spacing-sm $spacing-md, $font-size-sm);
&.primary {
background-color: $primary;
color: white;
border-color: $primary;
&:hover {
background-color: $primary-light;
border-color: $primary-light;
}
}
}
}
.recipe-alert {
background-color: rgba($warning, 0.1);
border-left: 4px solid $warning;
padding: $spacing-sm $spacing-md;
margin: 0 (-$spacing-xl) (-$spacing-xl);
display: flex;
align-items: center;
gap: $spacing-sm;
.alert-icon {
font-size: $font-size-lg;
color: $warning;
}
.alert-text {
font-size: $font-size-sm;
color: $text-primary;
font-weight: $font-weight-medium;
}
}
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
justify-content: center;
align-items: center;
}
-179
View File
@@ -1,179 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Header } from '../../components/Header/Header';
import { Loader } from '../../components/Loader/Loader';
import { apiService } from '../../services/api';
import type { AbraApp, AbraRecipe, AppWithServer } from '../../types';
import RecipeForm from './RecipeForm.tsx'
import './Recipes.scss';
export const Recipes: React.FC = () => {
const navigate = useNavigate();
const [recipesData, setRecipesData] = useState<AbraRecipe[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [filterServer, setFilterServer] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [selectedRecipe, setSelectedRecipe] = useState(null);
const [showForm, setShowForm] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
useEffect(() => {
const fetchData = async () => {
try {
if (isMockMode) {
const { mockApiService } = await import('../../services/mockApi');
const data = await mockApiService.getRecipes();
console.log(data)
setRecipesData(data);
} else {
const data = await apiService.getRecipes();
setRecipesData(data);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load apps');
} finally {
setLoading(false);
}
};
fetchData();
}, [isMockMode]);
// Flatten and enrich apps data
const allRecipes: AbraRecipe[] = useMemo(() => {
if (!recipesData) return [];
return recipesData;
}, [recipesData]);
// Filter recipes
const filteredRecipes = useMemo(() => {
return allRecipes.filter(recipe => {
const matchesSearch =
recipe.name.toLowerCase().includes(searchTerm.toLowerCase());
return matchesSearch;
});
}, [allRecipes, searchTerm, filterServer, filterStatus]);
const stats = useMemo(() => {
const total = allRecipes.length;
return { total };
}, [allRecipes]);
if (loading) {
return (
<div className="apps-page">
<Header />
<main className="recipes-content">
<div className="loading">
<Loader />
<div>Loading recipes</div>
</div>
</main>
</div>
);
}
if (error) {
return (
<div className="recipes-page">
<Header />
<main className="recipes-content">
<div className="error">Error: {error}</div>
</main>
</div>
);
}
return (
<div className="recipes-page">
<Header />
<main className="recipes-content">
<div className="page-header">
<h1>Recipes</h1>
<p className="subtitle">{stats.total} recipes</p>
</div>
{/* Filters */}
<div className="filters">
<input
type="text"
placeholder="Search recipes by name or description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
</div>
{/* Server Cards */}
<div className="recipes-grid">
{filteredRecipes.length === 0 ? (
<div className="no-results">No recipes found matching your search</div>
) : (
filteredRecipes.map((recipe) => (
<div
key={recipe.name}
className="recipe-card"
>
<div className="recipe-header">
<div className="recipe-title">
<h3>{recipe.name}</h3>
</div>
<div>
{recipe.icon.length > 0 ? <img src={`${recipe.icon}`} /> : null }
</div>
</div>
<div className="recipe-stats">
<div className="stat-row">
<span className="stat-value">{recipe.description}</span>
</div>
</div>
<div className="recipe-actions">
<button
className="action-btn"
onClick={(e) => {
e.stopPropagation();
console.log('clicked');
setSelectedRecipe(recipe);
console.log('selectedRecipe:', selectedRecipe);
}}
>
Add Recipe
</button>
</div>
<div className="card-tags">
{recipe.features.backups.toLowerCase().includes("yes") ? <span className="tag-feature"> Backups </span> : null}
{recipe.features.healthcheck.toLowerCase().includes("yes") ? <span className="tag-feature"> Healthcheck </span> : null}
{recipe.category.length > 0 ? <span className="tag-category"> {recipe.category} </span> : null}
</div>
</div>
))
)}
</div>
{selectedRecipe && (
<div className="modal-overlay" onClick={() => setSelectedRecipe(null)}>
<div onClick={(e) => e.stopPropagation()}>
{}
<RecipeForm recipe={selectedRecipe} onClose={() => setSelectedRecipe(null)} />
</div>
</div>
)}
<div className="results-count">
Showing {filteredRecipes.length} of {allRecipes.length} apps
</div>
</main>
</div>
);
};
-340
View File
@@ -1,340 +0,0 @@
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
@use "sass:color";
.server-detail-page {
@extend .page-wrapper;
}
.server-detail-content {
@extend .page-content;
}
// Server header section
.server-header {
@include card;
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: $spacing-xl;
margin-bottom: $spacing-xl;
.server-title-section {
flex: 1;
min-width: 300px;
h1 {
margin: 0 0 $spacing-md 0;
font-size: $font-size-3xl;
color: $text-primary;
font-weight: $font-weight-bold;
}
.server-meta {
display: flex;
gap: $spacing-sm;
flex-wrap: wrap;
}
}
.server-actions {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
}
}
// Action buttons (shared with App view, could be moved to global)
.action-btn {
@include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm);
&.primary {
background: $primary;
color: white;
border-color: $primary;
&:hover:not(:disabled) {
background: $primary-light;
border-color: $primary-light;
}
}
&.secondary {
background: $bg-secondary;
color: $text-primary;
border-color: $border-color;
&:hover:not(:disabled) {
background: color.adjust($bg-secondary, $lightness: -10%);
}
}
&.danger {
background: $error;
color: white;
border-color: $error;
&:hover:not(:disabled) {
background: color.adjust($error, $lightness: -10%);
}
}
}
.back-button {
@extend .action-btn;
@extend .secondary;
}
// Badges - host badge is specific to server view
.host-badge {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
padding: $spacing-xs $spacing-md;
background: $bg-secondary;
color: $text-secondary;
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
font-family: monospace;
}
.upgrade-badge {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
padding: $spacing-xs $spacing-md;
background: rgba($warning, 0.1);
background: color.adjust($error, $lightness: -20%);
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
}
// Content grid layout
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: $spacing-xl;
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
// Info cards
.info-card {
@include card;
margin-bottom: $spacing-xl;
&:last-child {
margin-bottom: 0;
}
h2 {
margin: 0 0 $spacing-lg 0;
font-size: $font-size-xl;
color: $text-primary;
font-weight: $font-weight-bold;
}
}
// Info grid
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $spacing-lg;
}
.info-item {
display: flex;
flex-direction: column;
gap: $spacing-xs;
label {
font-size: $font-size-xs;
color: $text-secondary;
font-weight: $font-weight-medium;
text-transform: uppercase;
letter-spacing: 0.5px;
}
span {
font-size: $font-size-base;
color: $text-primary;
}
.stat-value {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-primary;
&.warning {
background: color.adjust($warning, $lightness: -10%);
}
&.chaos {
background: color.adjust($info, $lightness: -10%);
}
}
.no-value {
color: $text-muted;
font-style: italic;
}
}
// Apps list in server detail
.no-apps {
text-align: center;
padding: $spacing-2xl;
color: $text-secondary;
font-size: $font-size-base;
}
.apps-list {
@include card-list-vertical;
}
.app-item {
padding: $spacing-md;
background: $bg-secondary;
border-radius: $radius-md;
border: 2px solid transparent;
cursor: pointer;
transition: all $transition-base;
&:hover {
border-color: $primary;
background: white;
transform: translateX(4px);
}
.app-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: $spacing-md;
margin-bottom: $spacing-sm;
.app-item-name {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-primary;
}
.app-item-badges {
display: flex;
gap: $spacing-xs;
flex-wrap: wrap;
}
}
.app-item-details {
display: flex;
align-items: center;
gap: $spacing-md;
font-size: $font-size-sm;
.app-item-version {
color: $text-secondary;
font-family: monospace;
}
.app-item-domain {
color: $primary;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
// Quick actions list
.action-list {
display: flex;
flex-direction: column;
gap: $spacing-sm;
.action-list-item {
display: flex;
align-items: center;
gap: $spacing-md;
padding: $spacing-md;
background: $bg-secondary;
border: 2px solid transparent;
border-radius: $radius-md;
cursor: pointer;
transition: all $transition-base;
font-size: $font-size-sm;
color: $text-primary;
text-align: left;
width: 100%;
&:hover:not(:disabled) {
background: white;
border-color: $primary;
transform: translateX(2px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.danger {
&:hover:not(:disabled) {
border-color: $error;
background: rgba($error, 0.05);
}
}
.action-text {
flex: 1;
font-weight: $font-weight-medium;
}
}
}
// Health status
.health-status {
display: flex;
flex-direction: column;
gap: $spacing-md;
.health-item {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: $spacing-md;
border-bottom: 1px solid $border-color;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
.health-label {
font-size: $font-size-sm;
color: $text-secondary;
}
.health-value {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-primary;
&.status-online {
color: $success;
}
&.status-offline {
color: $error;
}
}
}
}
-378
View File
@@ -1,378 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Header } from '../../components/Header/Header';
import { Loader } from '../../components/Loader/Loader';
import { Terminal } from '../../components/Terminal/Terminal';
import { apiService } from '../../services/api';
import type { AbraServer, ServerAppsResponse } from '../../types';
import type { LogEntry } from '../../services/mockApi';
import './Server.scss';
interface ServerWithApps extends AbraServer {
apps: any[];
appCount: number;
upgradeCount: number;
versionCount: number;
unversionedCount: number;
latestCount: number;
}
export const Server: React.FC = () => {
const { serverName } = useParams<{ serverName: string }>();
const navigate = useNavigate();
const [server, setServer] = useState<ServerWithApps | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState<string | null>(null);
// Terminal state
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
const [terminalActive, setTerminalActive] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
useEffect(() => {
const fetchServer = async () => {
try {
if (isMockMode) {
const { mockApiService } = await import('../../services/mockApi');
const appsData = await mockApiService.getAppsGrouped();
const serversData = await mockApiService.getServers();
const foundServer = serversData.find(s => s.name === serverName);
const serverApps = appsData[serverName || ''];
if (foundServer && serverApps) {
setServer({
...foundServer,
apps: serverApps.apps,
appCount: serverApps.appCount,
upgradeCount: serverApps.upgradeCount,
versionCount: serverApps.versionCount,
unversionedCount: serverApps.unversionedCount,
latestCount: serverApps.latestCount,
});
} else {
setError('Server not found');
}
} else {
const appsData = await apiService.getAppsGrouped();
const serversData = await apiService.getServers();
const foundServer = serversData.find(s => s.name === serverName);
const serverApps = appsData[serverName || ''];
if (foundServer && serverApps) {
setServer({
...foundServer,
apps: serverApps.apps,
appCount: serverApps.appCount,
upgradeCount: serverApps.upgradeCount,
versionCount: serverApps.versionCount,
unversionedCount: serverApps.unversionedCount,
latestCount: serverApps.latestCount,
});
} else {
setError('Server not found');
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load server');
} finally {
setLoading(false);
}
};
fetchServer();
}, [serverName, isMockMode]);
const handleAction = async (action: string) => {
if (!server) return;
setActionLoading(action);
setTerminalActive(true);
setTerminalLogs([]);
try {
if (isMockMode) {
const { mockApiService } = await import('../../services/mockApi');
const onLog = (log: LogEntry) => {
setTerminalLogs(prev => [...prev, log]);
};
switch (action) {
case 'refresh':
await mockApiService.refreshServer(server.name, onLog);
break;
case 'deploy-all':
await mockApiService.deployAllApps(server.name, server.appCount, onLog);
break;
case 'upgrade-all':
await mockApiService.upgradeAllApps(server.name, server.upgradeCount, onLog);
break;
}
} else {
// Real API calls
console.log(`Action: ${action} on server ${server.name}`);
}
} catch (err) {
console.error('Action failed:', err);
setTerminalLogs(prev => [...prev, {
type: 'error',
text: `❌ Error: ${err instanceof Error ? err.message : 'Action failed'}`,
timestamp: new Date()
}]);
} finally {
setActionLoading(null);
}
};
if (loading) {
return (
<div className="server-detail-page">
<Header />
<main className="server-detail-content">
<div className="loading">
<Loader />
<div> Loading server</div>
</div>
</main>
</div>
);
}
if (error || !server) {
return (
<div className="server-detail-page">
<Header />
<main className="server-detail-content">
<div className="error">{error || 'Server not found'}</div>
<button onClick={() => navigate('/servers')} className="back-button">
Back to Servers
</button>
</main>
</div>
);
}
const chaosApps = server.apps.filter(app => app.chaos === 'true');
const runningApps = server.apps.filter(app => app.status === 'deployed' || app.status === 'running');
return (
<div className="server-detail-page">
<Header />
<main className="server-detail-content">
<div className="back-navigation">
<button
onClick={() => navigate(`/servers`)}
className="server-link"
>
← Back to Servers
</button>
</div>
<div className="server-header">
<div className="server-title-section">
<h1>{server.name}</h1>
<div className="server-meta">
<span className="host-badge">{server.host}</span>
{server.upgradeCount > 0 && (
<span className="upgrade-badge">
{server.upgradeCount} upgrade{server.upgradeCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
<div className="server-actions">
<button
className="action-btn secondary"
onClick={() => handleAction('refresh')}
disabled={!!actionLoading}
>
{actionLoading === 'refresh' ? 'Refreshing...' : 'Refresh'}
</button>
<button
className="action-btn"
onClick={() => handleAction('deploy-all')}
disabled={!!actionLoading}
>
{actionLoading === 'deploy-all' ? 'Deploying...' : 'Deploy All'}
</button>
</div>
</div>
{/* Terminal Component */}
<Terminal
logs={terminalLogs}
isActive={terminalActive}
onClose={() => setTerminalActive(false)}
/>
<div className="content-grid">
{/* Left Column - Main Info */}
<div className="main-column">
<section className="info-card">
<h2>Server Details</h2>
<div className="info-grid">
<div className="info-item">
<label>Server Name</label>
<span>{server.name}</span>
</div>
<div className="info-item">
<label>Host</label>
<span>{server.host}</span>
</div>
<div className="info-item">
<label>Total Apps</label>
<span className="stat-value">{server.appCount}</span>
</div>
<div className="info-item">
<label>Running Apps</label>
<span className="stat-value">{runningApps.length}</span>
</div>
<div className="info-item">
<label>Upgrades Available</label>
<span className={server.upgradeCount > 0 ? 'stat-value warning' : 'stat-value'}>
{server.upgradeCount}
</span>
</div>
<div className="info-item">
<label>Chaos Mode Apps</label>
<span className={chaosApps.length > 0 ? 'stat-value chaos' : 'stat-value'}>
{chaosApps.length}
</span>
</div>
<div className="info-item">
<label>Latest Versions</label>
<span className="stat-value">{server.latestCount}</span>
</div>
<div className="info-item">
<label>Versioned Apps</label>
<span className="stat-value">{server.versionCount}</span>
</div>
</div>
</section>
<section className="info-card">
<h2>Applications on this Server</h2>
{server.apps.length === 0 ? (
<div className="no-apps">No applications deployed on this server</div>
) : (
<div className="apps-list">
{server.apps.map((app, idx) => (
<div
key={`${app.appName}-${idx}`}
className="app-item"
onClick={() => navigate(`/apps/${server.name}/${app.appName}`)}
>
<div className="app-item-header">
<span className="app-item-name">{app.appName}</span>
<div className="app-item-badges">
<span className="recipe-badge">{app.recipe}</span>
<span className={`status-badge status-${app.status}`}>
{app.status}
</span>
{app.chaos === 'true' && (
<span className="chaos-badge">☠️</span>
)}
{app.upgrade !== 'latest' && (
<span className="upgrade-badge">⬆️</span>
)}
</div>
</div>
<div className="app-item-details">
<span className="app-item-version">v{app.version}</span>
{app.domain && (
<a
href={`https://${app.domain}`}
target="_blank"
rel="noopener noreferrer"
className="app-item-domain"
onClick={(e) => e.stopPropagation()}
>
{app.domain}
</a>
)}
</div>
</div>
))}
</div>
)}
</section>
</div>
{/* Right Column - Actions & Stats */}
<div className="sidebar-column">
<section className="info-card">
<h2>Quick Actions</h2>
<div className="action-list">
<button
className="action-list-item"
onClick={() => handleAction('refresh')}
disabled={!!actionLoading}
>
<span className="action-text">Refresh Server Info</span>
</button>
<button
className="action-list-item"
onClick={() => handleAction('deploy-all')}
disabled={!!actionLoading}
>
<span className="action-text">Deploy All Apps</span>
</button>
<button
className="action-list-item"
onClick={() => handleAction('upgrade-all')}
disabled={!!actionLoading || server.upgradeCount === 0}
>
<span className="action-text">Upgrade All Apps</span>
</button>
</div>
</section>
<section className="info-card">
<h2>Server Health</h2>
<div className="health-status">
<div className="health-item">
<span className="health-label">Status</span>
<span className="health-value status-online">🟢 Online</span>
</div>
<div className="health-item">
<span className="health-label">CPU Usage</span>
<span className="health-value">—</span>
</div>
<div className="health-item">
<span className="health-label">Memory</span>
<span className="health-value">—</span>
</div>
<div className="health-item">
<span className="health-label">Disk Space</span>
<span className="health-value">—</span>
</div>
<div className="health-item">
<span className="health-label">Uptime</span>
<span className="health-value">—</span>
</div>
</div>
</section>
</div>
</div>
</main>
</div>
);
};
-157
View File
@@ -1,157 +0,0 @@
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
// Extend global page wrapper
.servers-page {
@extend .page-wrapper;
}
.servers-content {
@extend .page-content;
}
// Servers grid
.servers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: $spacing-xl;
margin-bottom: $spacing-xl;
align-items: stretch;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
// Server card
.server-card {
@include card;
@include card-hover-lift(-4px, $shadow-xl);
@include card-dimensions(320px, 180px);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
.server-header {
@include card-header-rule;
.server-title {
@include card-title-stack;
}
.server-status {
.status-indicator {
font-size: $font-size-xl;
&.connected {
color: $success;
}
&.disconnected {
color: $error;
}
}
}
}
.server-stats {
flex: 1;
margin-bottom: $spacing-lg;
.stat-row {
@include card-stat-row;
// Highlighted rows
&.highlight {
@include card-row-highlight-bleed;
background-color: rgba($warning, 0.05);
.stat-label {
font-weight: $font-weight-semibold;
}
.stat-value {
font-weight: $font-weight-bold;
}
}
&.chaos-row {
@include card-row-highlight-bleed(false);
background-color: rgba($info, 0.05);
.stat-label {
font-weight: $font-weight-semibold;
}
.stat-value {
font-weight: $font-weight-bold;
}
}
.stat-label {
font-size: $font-size-sm;
color: $text-secondary;
display: flex;
align-items: center;
gap: $spacing-xs;
.upgrade-icon,
.chaos-icon {
font-size: $font-size-base;
}
}
.stat-value {
font-size: $font-size-lg;
color: $text-primary;
font-weight: $font-weight-semibold;
}
}
}
.server-actions {
display: flex;
gap: $spacing-sm;
margin-bottom: $spacing-md;
.action-btn {
flex: 1;
@include action-btn(2px, $radius-md, $spacing-sm $spacing-md, $font-size-sm);
&.primary {
// background-color: $primary;
// color: white;
// border-color: $primary;
&:hover {
background-color: $primary-light;
border-color: $primary-light;
}
}
}
}
.server-alert {
background-color: rgba($warning, 0.1);
border-left: 4px solid $warning;
padding: $spacing-sm $spacing-md;
margin: 0 (-$spacing-xl) (-$spacing-xl);
display: flex;
align-items: center;
gap: $spacing-sm;
.alert-icon {
font-size: $font-size-lg;
color: $warning;
}
.alert-text {
font-size: $font-size-sm;
color: $text-primary;
font-weight: $font-weight-medium;
}
}
}
-259
View File
@@ -1,259 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Header } from '../../components/Header/Header';
import { Loader } from '../../components/Loader/Loader';
import { apiService } from '../../services/api';
import type { AbraServer, ServerAppsResponse } from '../../types';
import './Servers.scss';
interface ServerWithStats extends AbraServer {
appCount: number;
versionCount: number;
latestCount: number;
upgradeCount: number;
chaosCount: number;
backupEnabled: boolean;
}
export const Servers: React.FC = () => {
const navigate = useNavigate();
const [servers, setServers] = useState<ServerWithStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'name' | 'apps' | 'upgrades'>('name');
const [showUpgradesOnly, setShowUpgradesOnly] = useState(false);
const [showChaosOnly, setShowChaosOnly] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
useEffect(() => {
const fetchData = async () => {
try {
let serversData, appsData;
if (isMockMode) {
const { mockApiService } = await import('../../services/mockApi');
[serversData, appsData] = await Promise.all([
mockApiService.getServers(),
mockApiService.getAppsGrouped(),
]);
} else {
[serversData, appsData] = await Promise.all([
apiService.getServers(),
apiService.getAppsGrouped(),
]);
}
// Enrich servers with stats from apps data
const enrichedServers = serversData.map(server => {
const serverStats = appsData[server.name];
const chaosCount = serverStats?.apps.filter(app => app.chaos === 'true').length || 0;
return {
...server,
appCount: serverStats?.appCount || 0,
versionCount: serverStats?.versionCount || 0,
latestCount: serverStats?.latestCount || 0,
upgradeCount: serverStats?.upgradeCount || 0,
chaosCount,
backupEnabled: serverStats?.backupEnabled
};
});
setServers(enrichedServers);
} catch (err) {
console.error('Error loading servers:', err);
setError(err instanceof Error ? err.message : 'Failed to load servers');
} finally {
setLoading(false);
}
};
fetchData();
}, [isMockMode]);
// Calculate overall stats
const stats = useMemo(() => {
const totalServers = servers.length;
const totalApps = servers.reduce((sum, s) => sum + s.appCount, 0);
const totalUpgrades = servers.reduce((sum, s) => sum + s.upgradeCount, 0);
const totalChaos = servers.reduce((sum, s) => sum + s.chaosCount, 0);
return { totalServers, totalApps, totalUpgrades, totalChaos };
}, [servers]);
// Filter and sort servers (additive filters allowed)
const filteredServers = useMemo(() => {
const filtered = servers.filter(server => {
const matchesSearch =
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
server.host.toLowerCase().includes(searchTerm.toLowerCase());
const matchesUpgrades = !showUpgradesOnly || server.upgradeCount > 0;
const matchesChaos = !showChaosOnly || server.chaosCount > 0;
return matchesSearch && matchesUpgrades && matchesChaos;
});
filtered.sort((a, b) => {
switch (sortBy) {
case 'apps':
return b.appCount - a.appCount;
case 'name':
default:
return a.name.localeCompare(b.name);
}
});
return filtered;
}, [servers, searchTerm, sortBy, showUpgradesOnly]);
if (loading) {
return (
<div className="servers-page">
<Header />
<main className="servers-content">
<div className="loading">
<Loader />
<div> Loading servers</div>
</div>
</main>
</div>
);
}
if (error) {
return (
<div className="servers-page">
<Header />
<main className="servers-content">
<div className="error">Error: {error}</div>
</main>
</div>
);
}
return (
<div className="servers-page">
<Header />
<main className="servers-content">
<div className="page-header">
<h1>Servers</h1>
<p className="subtitle">Managing {stats.totalServers} servers with {stats.totalApps} applications</p>
</div>
{/* Compact Stats Row */}
<div className="stats-row">
<button
className="stat-chip"
onClick={() => navigate('/apps')}
title="View all apps"
>
<span className="stat-label">Apps</span>
<span className="stat-value">{stats.totalApps}</span>
</button>
<button
className={`stat-chip filter-chip ${showUpgradesOnly ? 'active' : ''}`}
onClick={() => setShowUpgradesOnly(prev => !prev)}
title="Click to filter by servers with upgrades"
disabled={stats.totalUpgrades === 0}
>
<span className="stat-label">Upgrades</span>
<span className="stat-value">{stats.totalUpgrades}</span>
</button>
<button
className={`stat-chip filter-chip ${showChaosOnly ? 'active' : ''}`}
onClick={() => setShowChaosOnly(prev => !prev)}
title="Click to filter servers with chaos apps"
disabled={stats.totalChaos === 0}
>
<span className="stat-label">Chaos</span>
<span className="stat-value">{stats.totalChaos}</span>
</button>
{/* Clear filters button removed — use stat-chip outlines for active filters */}
</div>
{/* Filters */}
<div className="filters">
<input
type="text"
placeholder="Search servers by name or host..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
</div>
{/* Server Cards */}
<div className="servers-grid">
{filteredServers.length === 0 ? (
<div className="no-results">No servers found matching your search</div>
) : (
filteredServers.map((server) => (
<div
key={server.name}
className="server-card"
onClick={() => navigate(`/servers/${server.name}`)}
style={{ cursor: 'pointer' }}
>
<div className="server-header">
<div className="server-title">
<h3>{server.name}</h3>
<span className="server-host">{server.host}</span>
</div>
<div className="server-status">
<span className="status-indicator connected" title="Connected">●</span>
</div>
</div>
<div className="server-stats">
<div className="stat-row">
<span className="stat-label">Applications</span>
<span className="stat-value">{server.appCount}</span>
</div>
<div className="stat-row">
<span className="stat-label">Versioned</span>
<span className="stat-value">{server.versionCount}</span>
</div>
<div className="stat-row">
<span className="stat-label">Latest</span>
<span className="stat-value">{server.latestCount}</span>
</div>
{server.upgradeCount > 0 && (
<div className="stat-row highlight">
<span className="stat-label">
Need Upgrade
</span>
<span className="stat-value">{server.upgradeCount}</span>
</div>
)}
{server.chaosCount > 0 && (
<div className="stat-row chaos-row">
<span className="stat-label">
Chaos Mode
</span>
<span className="stat-value">{server.chaosCount}</span>
</div>
)}
</div>
{!server.backupEnabled && (
<div className="server-alert">
<span className="alert-text">
No Backups Enabled
</span>
</div>
)}
</div>
))
)}
</div>
<div className="results-count">
Showing {filteredServers.length} of {servers.length} servers
</div>
</main>
</div>
);
};
-287
View File
@@ -1,287 +0,0 @@
import type { AbraServer, AbraRecipe, ServerAppsResponse, AbraAppService, DeployEvent, AbraAppSecret, File, AppSnapshot } from '../types';
// Log entry type
export type LogEntry = {
type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output';
text: string;
timestamp: Date;
};
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
// Helper to process log JSON from API response
const processLogResponse = (logData: any[]): LogEntry[] => {
return logData.map(log => ({
type: log.type,
text: log.text,
timestamp: new Date(log.timestamp || Date.now()),
}));
};
class ApiService {
private async request<T>(
endpoint: string,
options: RequestInit = {},
URLQueryString?: string
): Promise<T> {
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
const queryString = URLQueryString ?? ""
const response = await fetch(`${API_BASE_URL}${endpoint}` + queryString, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
if (response.status === 204) {
return undefined as T;
}
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json();
}
return response.text() as unknown as T;
}
private stream<T>(
endpoint: string,
handlers: {
onMessage: (data: T) => void;
onError?: (err: any) => void;
onOpen?: () => void;
parser?: (raw: string) => T;
}
) {
const es = new EventSource(`${API_BASE_URL}${endpoint}`);
es.onopen = () => {
handlers.onOpen?.();
};
es.onmessage = (event) => {
try {
const data = handlers.parser
? handlers.parser(event.data)
: (event.data as unknown as T);
handlers.onMessage(data);
} catch (err) {
handlers.onError?.(err);
}
};
es.onerror = (err) => {
handlers.onError?.(err);
es.close();
};
return () => es.close();
}
private async file(
endpoint: string,
options: RequestInit = {}
): Promise<Blob> {
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers,
})
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.blob()
}
// Get Logs for service
getLogs(appName: string, serviceName: string, msgHandler: (data: String) => void) {
return this.stream(`/apps/${appName}/${serviceName}/logs`, {onMessage: msgHandler})
}
async getConfig(appName: string): Promise<string> {
const file = await this.request<File>(`/apps/${appName}/config`)
return file.content
}
async writeConfig(appName: string, code: string): Promise<void> {
const file : File = { content: code };
return this.request<void>(`/apps/${appName}/config`, {
method: 'POST',
headers: {
'Accept': 'application/json'
},
body: JSON.stringify(file),
})
}
async getSecrets(appName: string): Promise<AbraAppSecret[]> {
return this.request<AbraAppSecret[]>(`/apps/${appName}/secret`);
}
async writeSecret(appName: string, secretName: string, version: string, value: string): Promise<void> {
console.log("writing secret ", appName, secretName, version, value);
return this.request<void>(`/apps/${appName}/secret`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify( {
'name': secretName,
'version': version,
'value': value,
})
});
}
async downloadSnapshot(appName: string): Promise<Blob> {
return this.file(`/apps/${appName}/backup/download`)
}
// Get all apps grouped by server
async getAppsGrouped(): Promise<ServerAppsResponse> {
return this.request<ServerAppsResponse>('/apps');
}
// Get all servers
async getServers(): Promise<AbraServer[]> {
return this.request<AbraServer[]>('/servers');
}
// Get services for app
async getServices(appName: string): Promise<AbraAppService[]> {
return this.request<AbraAppService[]>(`/apps/${appName}/services`);
}
// Get all backups for an app
async getBackupList(appName: string): Promise<AppSnapshot[]> {
return this.request<AppSnapshot[]>(`/apps/${appName}/backup/list`);
}
// Create backup for an app
async createBackup (appName: string): Promise<void> {
return this.request<void>(`/apps/${appName}/backup/create`, {
method: 'POST',
});
}
// restore backup for app
async restoreBackup (appName: string, snapshot?: string): Promise<void> {
const response = await this.request<void>(`/apps/${appName}/backup/restore`, {
method: 'POST',
body: JSON.stringify({snapshot: snapshot}),
});
return response
}
// App actions with log streaming (websocket future)
async deployApp(appName: string): Promise<void> {
return this.request<void>(`/apps/${appName}/deploy`)
}
deployLogs(appName: string, msgHandler: (data: DeployEvent) => void) {
return this.stream(`/apps/${appName}/deploy`, {parser: JSON.parse, onMessage: msgHandler})
}
async stopApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/stop`, {
method: 'POST',
});
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach(log => onLog(log));
}
}
async upgradeApp(appName: string, version?: string, force?: boolean, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/upgrade`, {
method: 'POST',
body: JSON.stringify({ version: version }),
},
"?" + new URLSearchParams({force: force ? 'true' : 'false'})
);
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach(log => onLog(log));
}
}
async removeApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/remove`, {
method: 'POST',
});
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach(log => onLog(log));
}
}
async rollbackApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/rollback`, {
method: 'POST',
body: JSON.stringify({ version: version }),
});
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach(log => onLog(log));
}
}
async newApp(appName: string, formData: Object): Promise<AbraSecret[]> {
const response = await this.request<AbraSecret[]>(`/apps/${appName}/new`, {
method: 'POST',
headers: {
'Accept': 'application/json'
},
body: JSON.stringify(formData)
});
return response
}
async getAppVersions(app: string): Promise<string[]> {
return this.request<string[]>(`/apps/${app}/versions`);
}
// Server actions with log streaming
async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/refresh`, {
method: 'POST',
});
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach(log => onLog(log));
}
}
async deployAllApps(serverName: string, appCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/deploy-all`, {
method: 'POST',
});
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach(log => onLog(log));
}
}
async upgradeAllApps(serverName: string, upgradeCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/upgrade-all`, {
method: 'POST',
});
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach(log => onLog(log));
}
}
// recipe catalog imports
async getRecipes(): Promise<AbraRecipe[]> {
return this.request<AbraRecipe[]>('/catalogue');
}
}
export const apiService = new ApiService();
-593
View File
@@ -1,593 +0,0 @@
{
"mydomain.com": {
"apps": [
{
"server": "mydomain.com",
"recipe": "authentik",
"appName": "accounts.mydomain.com",
"domain": "accounts.mydomain.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "7.4.0+2025.6.3",
"upgrade": "9.0.1+2025.8.1\n9.0.0+2025.8.1\n8.0.0+2025.8.1\n7.4.1+2025.6.4"
},
{
"server": "mydomain.com",
"recipe": "backup-bot-two",
"appName": "backup.mydomain.com",
"domain": "",
"status": "deployed",
"chaos": "false",
"chaosVersion": "2.3.0+2.3.0-beta",
"version": "2.3.0+2.3.0-beta",
"upgrade": "latest"
},
{
"server": "mydomain.com",
"recipe": "collabora",
"appName": "docs.mydomain.com",
"domain": "docs.mydomain.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "3.2.0+24.04.12.3.1",
"version": "3.2.0+24.04.12.3.1",
"upgrade": "4.0.0+25.04.4.1.1\n3.3.0+24.04.13.3.1"
},
{
"server": "mydomain.com",
"recipe": "nextcloud",
"appName": "nc.mydomain.com",
"domain": "nc.mydomain.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "12.0.1+31.0.6-fpm",
"upgrade": "latest"
},
{
"server": "mydomain.com",
"recipe": "traefik",
"appName": "traefik.mydomain.com",
"domain": "traefik.mydomain.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "3.4.2+v3.4.5",
"upgrade": "3.6.2+v3.4.5\n3.6.1+v3.4.5\n3.6.0+v3.4.5\n3.5.0+v3.4.5"
}
],
"appCount": 5,
"versionCount": 5,
"unversionedCount": 0,
"latestCount": 2,
"upgradeCount": 3
},
"coop.test.org": {
"apps": [
{
"server": "coop.test.org",
"recipe": "cryptpad",
"appName": "cryptpad.coop.test.org",
"domain": "cryptpad.coop.test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "cb2a47fb",
"version": "0.4.0+version-2024.3.0",
"upgrade": "latest"
},
{
"server": "coop.test.org",
"recipe": "garage",
"appName": "garage.coop.test.org",
"domain": "garage.coop.test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "3a729d56+U",
"version": "3a729d56+U",
"upgrade": "latest"
},
{
"server": "coop.test.org",
"recipe": "mobilizon",
"appName": "mobilizon.test.org",
"domain": "mobilizon.test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "f8f874a5 + unstaged changes",
"version": "0.2.1+5.1.2",
"upgrade": "latest"
},
{
"server": "coop.test.org",
"recipe": "owncast",
"appName": "tv.test.org",
"domain": "tv.test.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "0.3.0+0.2.1",
"version": "0.3.0+0.2.1",
"upgrade": "latest"
},
{
"server": "coop.test.org",
"recipe": "privatebin",
"appName": "paste.test.org",
"domain": "paste.test.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "0.6.1+0.17.4",
"version": "0.6.0+1.7.6",
"upgrade": "0.6.1+0.17.4"
},
{
"server": "coop.test.org",
"recipe": "traefik",
"appName": "traefik.coop.test.org",
"domain": "traefik.coop.test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "27d5c092",
"version": "27d5c092",
"upgrade": "latest"
}
],
"appCount": 6,
"versionCount": 6,
"unversionedCount": 0,
"latestCount": 5,
"upgradeCount": 1
},
"internal.website.com": {
"apps": [
{
"server": "internal.website.com",
"recipe": "backup-bot-two",
"appName": "backup.internal.website.com",
"domain": "backup.internal.website.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "2.3.0+2.3.0-beta",
"upgrade": "latest"
},
{
"server": "internal.website.com",
"recipe": "baserow",
"appName": "base.website.com",
"domain": "base.website.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "1.1.0+1.31.1",
"upgrade": "1.2.1+1.35.3\n1.2.0+1.35.3"
},
{
"server": "internal.website.com",
"recipe": "traefik",
"appName": "traefik.internal.website.com",
"domain": "traefik.internal.website.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "3.6.2+v3.4.5",
"upgrade": "latest"
}
],
"appCount": 3,
"versionCount": 3,
"unversionedCount": 0,
"latestCount": 2,
"upgradeCount": 1
},
"internal.server.net": {
"apps": [
{
"server": "internal.server.net",
"recipe": "authentik",
"appName": "accounts.server.net",
"domain": "accounts.server.net",
"status": "deployed",
"chaos": "false",
"chaosVersion": "6.11.0+2024.10.5",
"version": "6.11.0+2024.10.5",
"upgrade": "9.0.1+2025.8.1\n9.0.0+2025.8.1\n8.0.0+2025.8.1\n7.4.1+2025.6.4\n7.4.0+2025.6.3\n7.3.2+2025.6.2\n7.3.1+2025.6.1\n7.3.0+2025.6.0\n7.2.0+2025.4.1\n7.1.0+2025.2.4\n7.0.3+2025.2.3\n7.0.2+2025.2.2\n7.0.1+2025.2.0\n7.0.0+2025.2.0\n6.12.0+2024.12.3\n6.11.1+2024.10.5"
},
{
"server": "internal.server.net",
"recipe": "authentik",
"appName": "accounts.test.server.net",
"domain": "accounts.test.server.net",
"status": "deployed",
"chaos": "false",
"chaosVersion": "7.1.0+2025.2.4",
"version": "7.1.0+2025.2.4",
"upgrade": "9.0.1+2025.8.1\n9.0.0+2025.8.1\n8.0.0+2025.8.1\n7.4.1+2025.6.4\n7.4.0+2025.6.3\n7.3.2+2025.6.2\n7.3.1+2025.6.1\n7.3.0+2025.6.0\n7.2.0+2025.4.1"
},
{
"server": "internal.server.net",
"recipe": "backup-bot-two",
"appName": "backup.server.net",
"domain": "",
"status": "deployed",
"chaos": "false",
"chaosVersion": "2.3.0+2.3.0-beta",
"version": "2.3.0+2.3.0-beta",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "custom-html",
"appName": "counterspy.zip",
"domain": "counterspy.zip",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "1.11.0+1.29.0",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "garage",
"appName": "garage.server.net",
"domain": "garage.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "20bfd2c6+U",
"version": "20bfd2c6+U",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "gitlab",
"appName": "git.server.net",
"domain": "git.server.net",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "0.2.2+18.3.0-ce.0",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "jitsi",
"appName": "jitsi.server.net",
"domain": "jitsi.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "d3dd84ab + unstaged changes",
"version": "0.0.1+8719",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "liberaforms",
"appName": "forms.test.server.net",
"domain": "forms.test.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "b3e74fa7+U",
"version": "b3e74fa7+U",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "mattermost",
"appName": "mattermost-demo.server.net",
"domain": "mattermost-demo.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "52580808",
"version": "52580808",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "openemr",
"appName": "openemr.server.net",
"domain": "openemr.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "6b62e331",
"version": "6b62e331",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "outline",
"appName": "wiki.server.net",
"domain": "wiki.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "ab70b3c4+U",
"version": "ab70b3c4+U",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "statping",
"appName": "status.server.net",
"domain": "status.server.net",
"status": "deployed",
"chaos": "true",
"chaosVersion": "bd875e08",
"version": "0.1.1+v0.90.78",
"upgrade": "latest"
},
{
"server": "internal.server.net",
"recipe": "traefik",
"appName": "traefik.server.net",
"domain": "traefik.server.net",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "3.6.2+v3.4.5",
"upgrade": "latest"
}
],
"appCount": 13,
"versionCount": 13,
"unversionedCount": 0,
"latestCount": 11,
"upgradeCount": 2
},
"internal.intranet.site.com": {
"apps": [
{
"server": "internal.intranet.site.com",
"recipe": "backup-bot-two",
"appName": "backup.intranet.site.com",
"domain": "",
"status": "deployed",
"chaos": "false",
"chaosVersion": "2.3.0+2.3.0-beta",
"version": "2.3.0+2.3.0-beta",
"upgrade": "latest"
},
{
"server": "internal.intranet.site.com",
"recipe": "baserow",
"appName": "base.intranet.site.com",
"domain": "base.intranet.site.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "1.1.0+1.31.1",
"upgrade": "1.2.1+1.35.3\n1.2.0+1.35.3"
},
{
"server": "internal.intranet.site.com",
"recipe": "traefik",
"appName": "traefik.intranet.site.com",
"domain": "traefik.intranet.site.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "3.4.2+v3.4.5",
"upgrade": "3.6.2+v3.4.5\n3.6.1+v3.4.5\n3.6.0+v3.4.5\n3.5.0+v3.4.5"
},
{
"server": "internal.intranet.site.com",
"recipe": "vaultwarden",
"appName": "pass.intranet.site.com",
"domain": "pass.intranet.site.com",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "2.0.0+1.33.2",
"upgrade": "2.1.0+1.34.1"
}
],
"appCount": 4,
"versionCount": 4,
"unversionedCount": 0,
"latestCount": 1,
"upgradeCount": 3
},
"internal.test.org": {
"apps": [
{
"server": "internal.test.org",
"recipe": "authentik",
"appName": "accounts.test.org",
"domain": "accounts.test.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "9.0.0+2025.8.1",
"upgrade": "9.0.1+2025.8.1"
},
{
"server": "internal.test.org",
"recipe": "backup-bot-two",
"appName": "backup.test.org",
"domain": "",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "2.3.0+2.3.0-beta",
"upgrade": "latest"
},
{
"server": "internal.test.org",
"recipe": "collabora",
"appName": "docs.test.org",
"domain": "docs.test.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "3.1.0+24.04.10.2.1",
"version": "3.1.0+24.04.10.2.1",
"upgrade": "4.0.0+25.04.4.1.1\n3.3.0+24.04.13.3.1\n3.2.0+24.04.12.3.1"
},
{
"server": "internal.test.org",
"recipe": "custom-html",
"appName": "test.org",
"domain": "test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "ba1c2269",
"version": "1.7.1+1.27.2",
"upgrade": "latest"
},
{
"server": "internal.test.org",
"recipe": "garage",
"appName": "garage.test.org",
"domain": "garage.test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "9ca66f0f",
"version": "9ca66f0f",
"upgrade": "latest"
},
{
"server": "internal.test.org",
"recipe": "nextcloud",
"appName": "nc.test.org",
"domain": "nc.test.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "11.0.0+30.0.4-fpm",
"version": "11.0.0+30.0.4-fpm",
"upgrade": "12.0.1+31.0.6-fpm\n12.0.0+31.0.6-fpm\n11.4.0+30.0.10-fpm\n11.4.0+30.0.6-fpm\n11.3.0+30.0.6-fpm\n11.2.0+30.0.6-fpm\n11.1.0+30.0.6-fpm\n11.0.1+30.0.4-fpm"
},
{
"server": "internal.test.org",
"recipe": "outline",
"appName": "wiki.test.org",
"domain": "wiki.test.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "2.8.1+0.81.1",
"version": "2.8.1+0.81.1",
"upgrade": "2.12.1+0.87.4\n2.12.0+0.87.3\n2.11.0+0.86.1\n2.10.0+0.84.0\n2.9.1+0.82.0\n2.9.0+0.82.0\n2.8.2+0.81.1"
},
{
"server": "internal.test.org",
"recipe": "traefik",
"appName": "traefik.test.org",
"domain": "traefik.test.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "993ed9cf",
"version": "993ed9cf",
"upgrade": "latest"
}
],
"appCount": 8,
"versionCount": 8,
"unversionedCount": 0,
"latestCount": 4,
"upgradeCount": 4
},
"orgsite.org": {
"apps": [
{
"server": "orgsite.org",
"recipe": "authentik",
"appName": "accounts.orgsite.org",
"domain": "accounts.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "6.11.0+2024.10.5",
"version": "6.11.0+2024.10.5",
"upgrade": "9.0.1+2025.8.1\n9.0.0+2025.8.1\n8.0.0+2025.8.1\n7.4.1+2025.6.4\n7.4.0+2025.6.3\n7.3.2+2025.6.2\n7.3.1+2025.6.1\n7.3.0+2025.6.0\n7.2.0+2025.4.1\n7.1.0+2025.2.4\n7.0.3+2025.2.3\n7.0.2+2025.2.2\n7.0.1+2025.2.0\n7.0.0+2025.2.0\n6.12.0+2024.12.3\n6.11.1+2024.10.5"
},
{
"server": "orgsite.org",
"recipe": "backup-bot-two",
"appName": "backup.orgsite.org",
"domain": "",
"status": "deployed",
"chaos": "false",
"chaosVersion": "2.3.0+2.3.0-beta",
"version": "2.3.0+2.3.0-beta",
"upgrade": "latest"
},
{
"server": "orgsite.org",
"recipe": "baserow",
"appName": "base.orgsite.org",
"domain": "base.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "1.1.0+1.31.1",
"upgrade": "1.2.1+1.35.3\n1.2.0+1.35.3"
},
{
"server": "orgsite.org",
"recipe": "collabora",
"appName": "docs.orgsite.org",
"domain": "docs.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "3.1.0+24.04.10.2.1",
"version": "3.1.0+24.04.10.2.1",
"upgrade": "4.0.0+25.04.4.1.1\n3.3.0+24.04.13.3.1\n3.2.0+24.04.12.3.1"
},
{
"server": "orgsite.org",
"recipe": "jitsi",
"appName": "meet.orgsite.org",
"domain": "meet.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "d3dd84ab",
"version": "0.0.1+8719",
"upgrade": "latest"
},
{
"server": "orgsite.org",
"recipe": "mattermost",
"appName": "chat.orgsite.org",
"domain": "chat.orgsite.org",
"status": "deployed",
"chaos": "true",
"chaosVersion": "5f0295b9",
"version": "2.1.0+10.5.0",
"upgrade": "latest"
},
{
"server": "orgsite.org",
"recipe": "nextcloud",
"appName": "nc.orgsite.org",
"domain": "nc.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "11.1.0+30.0.6-fpm",
"version": "11.1.0+30.0.6-fpm",
"upgrade": "12.0.1+31.0.6-fpm\n12.0.0+31.0.6-fpm\n11.4.0+30.0.10-fpm\n11.4.0+30.0.6-fpm\n11.3.0+30.0.6-fpm\n11.2.0+30.0.6-fpm"
},
{
"server": "orgsite.org",
"recipe": "traefik",
"appName": "traefik.orgsite.org",
"domain": "traefik.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "3.4.2+v3.4.5",
"upgrade": "3.6.2+v3.4.5\n3.6.1+v3.4.5\n3.6.0+v3.4.5\n3.5.0+v3.4.5"
},
{
"server": "orgsite.org",
"recipe": "vaultwarden",
"appName": "pass.orgsite.org",
"domain": "pass.orgsite.org",
"status": "deployed",
"chaos": "false",
"chaosVersion": "unknown",
"version": "2.0.0+1.33.2",
"upgrade": "2.1.0+1.34.1"
}
],
"appCount": 9,
"versionCount": 9,
"unversionedCount": 0,
"latestCount": 3,
"upgradeCount": 6
}
}

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