Update Dockerfile, add 'nightly' build tag, and update readme #5
@@ -42,3 +42,4 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:nightly-${{ env.COMMIT_SHORT_SHA }}
|
||||
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:nightly
|
||||
|
||||
@@ -50,4 +50,4 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:${{ env.RELEASE_TAG }}
|
||||
tags: git.coopcloud.tech/toolshed/coop-cloud-webui:${{ env.RELEASE_TAG }}
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "web"]
|
||||
path = web
|
||||
url = https://git.coopcloud.tech/toolshed/coop-cloud-front.git
|
||||
|
||||
+12
-6
@@ -3,17 +3,23 @@ RUN mkdir /backend
|
||||
COPY --parents ["cli", "internal", "go.mod", "go.sum", "app.go", "/backend/"]
|
||||
RUN go build -C /backend -o gobackend
|
||||
|
||||
FROM node:slim
|
||||
FROM git.coopcloud.tech/toolshed/abra@sha256:38de102e0d4cf40558bd354edb46fa9ae2438221742e74366560c1cfa0b4ced7 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/*
|
||||
RUN mkdir /home/node/wizard
|
||||
COPY --parents web /home/node/wizard
|
||||
COPY --from=0 /backend/gobackend /home/node/wizard/gobackend
|
||||
|
||||
USER node
|
||||
COPY ./command.sh /
|
||||
RUN curl https://install.abra.coopcloud.tech | bash
|
||||
ENV ABRA_BIN=/home/node/.local/bin/abra
|
||||
# RUN $ABRA_BIN recipe fetch -a
|
||||
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 .
|
||||
|
||||
@@ -3,16 +3,18 @@ A Go service that exposes RESTful API endpoints to manage Abra programmatically.
|
||||
Integrates with https://git.coopcloud.tech/toolshed/coop-cloud-front.
|
||||
|
||||
## Starting the service with Docker
|
||||
Build the container:
|
||||
Pull the container:
|
||||
```bash
|
||||
docker build -t coop-cloud-wizard:latest .
|
||||
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-wizard
|
||||
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`
|
||||
@@ -20,29 +22,19 @@ docker run -u 1000:1000 -p 5173:5173 -p 3000:3000 -v $HOME/.ssh:/home/node/.ssh
|
||||
- Start this Go app `go run .`
|
||||
- Navigate to the React App (http://localhost:5173)
|
||||
|
||||
> [!WARNING]
|
||||
> This is an extremely early prototype, only viewing/deploying apps is supported
|
||||
> and may fail for your local machine
|
||||
|
||||
## Notes:
|
||||
`api/` is a deprecated path, currently has no function
|
||||
|
||||
# Roadmap
|
||||
## General Roadmap:
|
||||
Currently refactored the backend to be much more Abra CLI reliant. This is ok but is not ideal from an engineering standpoint (probably).
|
||||
First step is just to polish this backend & frontend, estimate 1 - 2 months to be in a fairly usable state.
|
||||
Write test suite for the full stack combination.
|
||||
Then we want to package this app into a dockerized form, easily to deploy and use on one's own server.
|
||||
Slowly introduce full Abra CLI functionality into app.
|
||||
TODO
|
||||
Repackaging
|
||||
## Basic Abra Features
|
||||
| Feature | Status | Follow-up |
|
||||
| ----------- | ----------- | ----------- |
|
||||
| More options for app deploy (esp chaos) | TODO | |
|
||||
| View and pull in recipes from catalogue | In Progress | |
|
||||
| Deploy should show deployment status similar to Abra CLI | In Progress | |
|
||||
| App screen should display services associated with app and their status | TODO | |
|
||||
| Easily show service logs | In Progress | |
|
||||
| Modify App config | TODO | |
|
||||
| Manage updates and rollbacks | 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 | |
|
||||
|
||||
-1
Submodule web deleted from e09aa9a1ca
@@ -0,0 +1,3 @@
|
||||
VITE_API_URL=http://localhost:3000/api
|
||||
VITE_WS_URL=ws://localhost:3000
|
||||
VITE_MOCK_AUTH=true
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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?
|
||||
@@ -0,0 +1,2 @@
|
||||
pnpm 10.20.0
|
||||
nodejs 22.12.0
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "97e400e8",
|
||||
"configHash": "539cfeba",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "b743bb36",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,50 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
<!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>
|
||||
Generated
+5178
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
esbuild: true
|
||||
inotify: false
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
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
|
After Width: | Height: | Size: 163 KiB |
@@ -0,0 +1,31 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,7 @@
|
||||
// 1. Configuration & helpers (no CSS output)
|
||||
@use 'variables';
|
||||
@use 'mixins';
|
||||
@use 'fonts';
|
||||
|
||||
// 2. Global base styles
|
||||
@use 'global';
|
||||
@@ -0,0 +1,52 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
)}
|
||||
@@ -0,0 +1,83 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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>,
|
||||
)
|
||||
@@ -0,0 +1,591 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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;
|
||||
@@ -0,0 +1,154 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,340 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,287 @@
|
||||
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();
|
||||
@@ -0,0 +1,593 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
{
|
||||
"deploy": {
|
||||
"logs": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "📦 Deploying {appName}..."
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"text": "$ abra app deploy {appName}"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Checking server connection..."
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ Connected to server"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Pulling latest images..."
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "latest: Pulling from library/traefik"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "a1b2c3d4: Pull complete"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "e5f6g7h8: Pull complete"
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ Images pulled successfully"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Creating containers..."
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Creating {appName}_app_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Creating {appName}_db_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Starting services..."
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Starting {appName}_app_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Starting {appName}_db_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ {appName} deployed successfully!"
|
||||
}
|
||||
]
|
||||
},
|
||||
"stop": {
|
||||
"logs": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "🛑 Stopping {appName}..."
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"text": "$ abra app stop {appName}"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Stopping {appName}_app_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Stopping {appName}_db_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ {appName} stopped"
|
||||
}
|
||||
]
|
||||
},
|
||||
"start": {
|
||||
"logs": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "▶️ Starting {appName}..."
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"text": "$ abra app start {appName}"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Starting {appName}_app_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Starting {appName}_db_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ {appName} started"
|
||||
}
|
||||
]
|
||||
},
|
||||
"upgrade": {
|
||||
"logs": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Upgrading {appName} to {version}..."
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"text": "$ abra app upgrade {appName}"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Pulling new version..."
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "{version}: Pulling from library/app"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "x9y8z7w6: Pull complete"
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ New version pulled"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Stopping old containers..."
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Stopping {appName}_app_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Starting new containers..."
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Starting {appName}_app_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ {appName} upgraded to {version}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"remove": {
|
||||
"logs": [
|
||||
{
|
||||
"type": "warning",
|
||||
"text": "⚠️ Removing {appName}..."
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"text": "$ abra app remove {appName}"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Stopping {appName}_app_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Stopping {appName}_db_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Removing {appName}_app_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Removing {appName}_db_1 ... done"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Removing network {appName}_default"
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ {appName} removed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"serverRefresh": {
|
||||
"logs": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "🔄 Refreshing server {serverName}..."
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"text": "$ abra server ls"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Fetching server status..."
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Checking connection..."
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ Connected"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Fetching app list..."
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ Found apps on {serverName}"
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ Server refreshed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"deployAll": {
|
||||
"logs": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "📦 Deploying all apps on {serverName}..."
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"text": "$ abra app deploy --all --server {serverName}"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Found {appCount} apps to deploy"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Deploying apps in parallel..."
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "[1/{appCount}] Deploying app_1..."
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ app_1 deployed"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "[2/{appCount}] Deploying app_2..."
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ app_2 deployed"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "[3/{appCount}] Deploying app_3..."
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ app_3 deployed"
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ All apps deployed on {serverName}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"upgradeAll": {
|
||||
"logs": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Upgrading all apps on {serverName}..."
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"text": "$ abra app upgrade --all --server {serverName}"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Found {upgradeCount} apps with available upgrades"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"text": "Upgrading apps sequentially..."
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "[1/{upgradeCount}] Upgrading app_1..."
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ app_1 upgraded"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "[2/{upgradeCount}] Upgrading app_2..."
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ app_2 upgraded"
|
||||
},
|
||||
{
|
||||
"type": "success",
|
||||
"text": "✓ All apps upgraded on {serverName}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"host": "mydomain.com",
|
||||
"name": "mydomain.com"
|
||||
},
|
||||
{
|
||||
"host": "coop.test.org",
|
||||
"name": "coop.test.org"
|
||||
},
|
||||
{
|
||||
"host": "internal.website.com",
|
||||
"name": "internal.website.com"
|
||||
},
|
||||
{
|
||||
"host": "internal.server.net",
|
||||
"name": "internal.server.net"
|
||||
},
|
||||
{
|
||||
"host": "internal.intranet.site.com",
|
||||
"name": "internal.intranet.site.com"
|
||||
},
|
||||
{
|
||||
"host": "internal.test.org",
|
||||
"name": "internal.test.org"
|
||||
},
|
||||
{
|
||||
"host": "orgsite.org",
|
||||
"name": "orgsite.org"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { AbraRecipe, AbraServer, ServerAppsResponse } from '../types';
|
||||
import appsData from './mock-apps.json';
|
||||
import serversData from './mock-servers.json';
|
||||
import logsData from './mock-logs.json';
|
||||
import catalogue from './mock-catalogue.json';
|
||||
|
||||
|
||||
// Log entry type
|
||||
export type LogEntry = {
|
||||
type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output';
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
// Type for the imported JSON structure
|
||||
type LogTemplate = {
|
||||
type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output';
|
||||
text: string;
|
||||
};
|
||||
|
||||
type LogsDataStructure = {
|
||||
[key: string]: {
|
||||
logs: LogTemplate[];
|
||||
};
|
||||
};
|
||||
|
||||
// Simulate API delay
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// Helper to replace template variables like {appName}, {version}
|
||||
const replaceVars = (text: string, vars: Record<string, string | number>): string => {
|
||||
let result = text;
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value));
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
// Helper to process log templates into LogEntry objects
|
||||
const processLogs = (
|
||||
action: string,
|
||||
vars: Record<string, string | number>
|
||||
): LogEntry[] => {
|
||||
console.log('Processing logs for action:', action);
|
||||
console.log('Loaded logsData:', logsData);
|
||||
|
||||
const typedLogsData = logsData as LogsDataStructure;
|
||||
const actionData = typedLogsData[action];
|
||||
|
||||
if (!actionData || !actionData.logs) {
|
||||
console.error(`No logs found for action: ${action}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('Found logs:', actionData.logs);
|
||||
|
||||
const templates = actionData.logs;
|
||||
return templates
|
||||
.filter(template => template && template.type && template.text) // Filter out any undefined/malformed entries
|
||||
.map(template => ({
|
||||
type: template.type,
|
||||
text: replaceVars(template.text, vars),
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
};
|
||||
|
||||
export const mockApiService = {
|
||||
async getAppsGrouped(): Promise<ServerAppsResponse> {
|
||||
await delay(500);
|
||||
return appsData as ServerAppsResponse;
|
||||
},
|
||||
|
||||
async getServers(): Promise<AbraServer[]> {
|
||||
await delay(300);
|
||||
return serversData as AbraServer[];
|
||||
},
|
||||
|
||||
async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const logs = processLogs('deploy', { appName });
|
||||
|
||||
for (const log of logs) {
|
||||
await delay(200);
|
||||
onLog?.(log);
|
||||
}
|
||||
},
|
||||
|
||||
async stopApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const logs = processLogs('stop', { appName });
|
||||
|
||||
for (const log of logs) {
|
||||
await delay(150);
|
||||
onLog?.(log);
|
||||
}
|
||||
},
|
||||
|
||||
async upgradeApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const logs = processLogs('upgrade', { appName, version });
|
||||
|
||||
for (const log of logs) {
|
||||
await delay(200);
|
||||
onLog?.(log);
|
||||
}
|
||||
},
|
||||
|
||||
async removeApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const logs = processLogs('remove', { appName });
|
||||
|
||||
for (const log of logs) {
|
||||
await delay(180);
|
||||
onLog?.(log);
|
||||
}
|
||||
},
|
||||
|
||||
async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const logs = processLogs('serverRefresh', { serverName });
|
||||
|
||||
for (const log of logs) {
|
||||
await delay(200);
|
||||
onLog?.(log);
|
||||
}
|
||||
},
|
||||
|
||||
async deployAllApps(serverName: string, appCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const logs = processLogs('deployAll', { serverName, appCount });
|
||||
|
||||
for (const log of logs) {
|
||||
await delay(250);
|
||||
onLog?.(log);
|
||||
}
|
||||
},
|
||||
|
||||
async upgradeAllApps(serverName: string, upgradeCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||
const logs = processLogs('upgradeAll', { serverName, upgradeCount });
|
||||
|
||||
for (const log of logs) {
|
||||
await delay(250);
|
||||
onLog?.(log);
|
||||
}
|
||||
},
|
||||
// recipe catalog imports
|
||||
async getRecipes(): Promise<AbraRecipe[]> {
|
||||
await delay(300);
|
||||
return catalogue as AbraRecipe[];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface AbraApp {
|
||||
server: string;
|
||||
recipe: string;
|
||||
appName: string;
|
||||
domain: string;
|
||||
status: 'deployed' | 'stopped' | 'unknown';
|
||||
chaos: string;
|
||||
chaosVersion: string;
|
||||
version: string;
|
||||
upgrade: string;
|
||||
}
|
||||
|
||||
export interface AbraServer {
|
||||
name: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface ServerAppsResponse {
|
||||
[serverName: string]: {
|
||||
apps: AbraApp[];
|
||||
appCount: number;
|
||||
versionCount: number;
|
||||
unversionedCount: number;
|
||||
latestCount: number;
|
||||
upgradeCount: number;
|
||||
backupEnabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppWithServer extends AbraApp {
|
||||
serverStats: {
|
||||
appCount: number;
|
||||
upgradeCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AbraAppService {
|
||||
service: string;
|
||||
chaos: boolean;
|
||||
created: string;
|
||||
image: string;
|
||||
ports: string;
|
||||
state: string;
|
||||
status: string;
|
||||
version: string;
|
||||
}
|
||||
export interface AbraServiceState {
|
||||
name: string;
|
||||
err: string;
|
||||
id: string;
|
||||
status: string;
|
||||
retries: number;
|
||||
health: string;
|
||||
rollback: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
export interface DeployState {
|
||||
count: number;
|
||||
total: number;
|
||||
failed: boolean;
|
||||
quit: boolean;
|
||||
}
|
||||
|
||||
export type DeployEvent =
|
||||
| {
|
||||
type: "service";
|
||||
data: AbraServiceState;
|
||||
}
|
||||
| {
|
||||
type: "done";
|
||||
data: DeployState;
|
||||
}
|
||||
export interface Image {
|
||||
image: string;
|
||||
rating: string
|
||||
source: string
|
||||
url: string
|
||||
}
|
||||
type RecipeVersions = Record<string, Record<string, ServiceMeta>>[];
|
||||
export interface ServiceMeta {
|
||||
image: string;
|
||||
tag: string
|
||||
}
|
||||
export interface Features {
|
||||
backups: string;
|
||||
email: string;
|
||||
healthcheck: string;
|
||||
image: Image;
|
||||
status: number;
|
||||
tests: string;
|
||||
sso: string;
|
||||
}
|
||||
export interface AbraRecipe {
|
||||
category: string;
|
||||
default_branch: string;
|
||||
description: string;
|
||||
features: Features;
|
||||
icon: string;
|
||||
name: string;
|
||||
repository: string;
|
||||
ssh_url: string;
|
||||
versions: RecipeVersions;
|
||||
website: string;
|
||||
}
|
||||
|
||||
export interface AbraAppSecret {
|
||||
name: string;
|
||||
version: string;
|
||||
value: string;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export interface File {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AppSnapshot {
|
||||
createdAt: string;
|
||||
snapshot: string;
|
||||
version: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user