Compare commits
3 Commits
dev
...
dev-nomock
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ecc158268 | |||
| 864640a15e | |||
| 9b1eaf168f |
5
.env.example
Normal file
5
.env.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# API base URL for the backend that wraps the abra CLI
|
||||||
|
VITE_API_URL=http://localhost:3000/api
|
||||||
|
|
||||||
|
# Set to 'true' to use mock data
|
||||||
|
VITE_MOCK_AUTH=true
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,7 +8,6 @@ pnpm-debug.log*
|
|||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
pnpm-lock.yaml
|
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|||||||
54
README.md
54
README.md
@ -1,53 +1,7 @@
|
|||||||
# Coop Cloud Front
|
# Coop Cloud Front
|
||||||
|
|
||||||
Frontend for Coop Cloud — a web UI wrapper around the `abra` CLI for
|
This is the frontend of a web wrapper for Coop Clouds abra CLI, letting users set up and manage VPSs and docker images through a web UI.
|
||||||
managing VPSs, containers and application deployments.
|
|
||||||
|
|
||||||
This repository contains a Vite + React + TypeScript app styled
|
## Still a work in progess!
|
||||||
with SCSS.
|
|
||||||
|
|
||||||
## Quick start
|
## This is built with react, typescript, scss, and vite
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
@ -5,10 +5,6 @@
|
|||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>coopcloud-frontend</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
5062
package-lock.json
generated
Normal file
5062
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"start:prod": "VITE_MOCK_AUTH=false pnpm start",
|
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
|||||||
3042
pnpm-lock.yaml
generated
Normal file
3042
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
34
src/App.tsx
34
src/App.tsx
@ -1,26 +1,26 @@
|
|||||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { Dashboard } from "./routes/Dashboard/Dashboard";
|
import { Dashboard } from './routes/Dashboard/Dashboard';
|
||||||
import { Apps } from "./routes/Apps/Apps";
|
import { Apps } from './routes/Apps/Apps';
|
||||||
import { AppDetail } from "./routes/Apps/App";
|
import { AppDetail } from './routes/Apps/App';
|
||||||
import { Servers } from "./routes/Servers/Servers";
|
import { Servers } from './routes/Servers/Servers';
|
||||||
import { Server } from "./routes/Servers/Server";
|
import { Server } from './routes/Servers/Server';
|
||||||
import { Recipes } from "./routes/Recipes/Recipes";
|
import { Recipes } from './routes/Recipes/Recipes';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/apps" element={<Apps />} />
|
<Route path="/apps" element={<Apps />} />
|
||||||
<Route path="/apps/:server/:appName" element={<AppDetail />} />
|
<Route path="/apps/:server/:appName" element={<AppDetail />} />
|
||||||
<Route path="/servers" element={<Servers />} />
|
<Route path="/servers" element={<Servers />} />
|
||||||
<Route path="/servers/:serverName" element={<Server />} />
|
<Route path="/servers/:serverName" element={<Server />} />
|
||||||
<Route path="/recipes" element={<Recipes />} />
|
<Route path="/recipes" element={<Recipes />} />
|
||||||
|
|
||||||
{/* 404 catch-all */}
|
{/* 404 catch-all */}
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,30 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Lora";
|
font-family: 'Lora';
|
||||||
src: url("/fonts/Lora-Italic.woff2") format("woff2");
|
src: url('https://coopcloud.tech/font/Lora-Italic.woff2') format('woff2');
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Manrope";
|
font-family: 'Manrope';
|
||||||
src: url("/fonts/manrope.light.woff2") format("woff2");
|
src: url('https://coopcloud.tech/font/manrope.light.woff2') format('woff2');
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Manrope";
|
font-family: 'Manrope';
|
||||||
src: url("/fonts/manrope.medium.woff2") format("woff2");
|
src: url('https://coopcloud.tech/font/manrope.medium.woff2') format('woff2');
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Manrope";
|
font-family: 'Manrope';
|
||||||
src: url("/fonts/manrope.bold.woff2") format("woff2");
|
src: url('https://coopcloud.tech/font/manrope.bold.woff2') format('woff2');
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
@use "./variables" as *;
|
@use './variables' as *;
|
||||||
@use "./mixins" as *;
|
@use './mixins' as *;
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -75,9 +75,7 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
padding: $spacing-xl;
|
padding: $spacing-xl;
|
||||||
transition:
|
transition: transform $transition-base, box-shadow $transition-base;
|
||||||
transform $transition-base,
|
|
||||||
box-shadow $transition-base;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
@ -87,7 +85,6 @@ body {
|
|||||||
// Modifier classes for colored borders
|
// Modifier classes for colored borders
|
||||||
&.upgrade {
|
&.upgrade {
|
||||||
border-left: 4px solid $primary-light;
|
border-left: 4px solid $primary-light;
|
||||||
cursor: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.chaos {
|
&.chaos {
|
||||||
@ -237,18 +234,6 @@ body {
|
|||||||
color: $text-secondary;
|
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
|
||||||
.results-count {
|
.results-count {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
@use "variables" as *;
|
@use 'variables' as *;
|
||||||
|
|
||||||
// Flexbox center
|
// Flexbox center
|
||||||
@mixin flex-center {
|
@mixin flex-center {
|
||||||
@ -9,122 +9,25 @@
|
|||||||
|
|
||||||
// Responsive breakpoints
|
// Responsive breakpoints
|
||||||
@mixin respond-to($breakpoint) {
|
@mixin respond-to($breakpoint) {
|
||||||
@if $breakpoint == "sm" {
|
@if $breakpoint == 'sm' {
|
||||||
@media (min-width: $breakpoint-sm) {
|
@media (min-width: $breakpoint-sm) { @content; }
|
||||||
@content;
|
} @else if $breakpoint == 'md' {
|
||||||
}
|
@media (min-width: $breakpoint-md) { @content; }
|
||||||
} @else if $breakpoint == "md" {
|
} @else if $breakpoint == 'lg' {
|
||||||
@media (min-width: $breakpoint-md) {
|
@media (min-width: $breakpoint-lg) { @content; }
|
||||||
@content;
|
} @else if $breakpoint == 'xl' {
|
||||||
}
|
@media (min-width: $breakpoint-xl) { @content; }
|
||||||
} @else if $breakpoint == "lg" {
|
|
||||||
@media (min-width: $breakpoint-lg) {
|
|
||||||
@content;
|
|
||||||
}
|
|
||||||
} @else if $breakpoint == "xl" {
|
|
||||||
@media (min-width: $breakpoint-xl) {
|
|
||||||
@content;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card style
|
// Card style
|
||||||
@mixin card {
|
@mixin card {
|
||||||
background: $bg-primary;
|
background: $bg-primary;
|
||||||
|
border-radius: $radius-lg;
|
||||||
box-shadow: $shadow-md;
|
box-shadow: $shadow-md;
|
||||||
padding: $spacing-xl;
|
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
|
// Button base
|
||||||
@mixin button-base {
|
@mixin button-base {
|
||||||
padding: $spacing-sm $spacing-lg;
|
padding: $spacing-sm $spacing-lg;
|
||||||
@ -133,7 +36,7 @@
|
|||||||
font-weight: $font-weight-semibold;
|
font-weight: $font-weight-semibold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all $transition-base;
|
transition: all $transition-base;
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@ -160,68 +63,4 @@
|
|||||||
font-weight: $font-weight-semibold;
|
font-weight: $font-weight-semibold;
|
||||||
background-color: rgba($color, 0.1);
|
background-color: rgba($color, 0.1);
|
||||||
// color: darken($color, 20%);
|
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
// Colors
|
// Colors
|
||||||
$primary: #efefef;
|
$primary: #EFEFEF;
|
||||||
$primary-dark: #6a9cff;
|
$primary-dark: #6A9CFF;
|
||||||
$primary-light: #ff4f88;
|
$primary-light: #ff4f88;
|
||||||
$secondary: #363636;
|
$secondary: #363636;
|
||||||
|
|
||||||
@ -10,7 +10,8 @@ $error: #ef4444;
|
|||||||
$info: #3b82f6;
|
$info: #3b82f6;
|
||||||
|
|
||||||
$text-primary: #363636;
|
$text-primary: #363636;
|
||||||
$text-secondary: #4a4a4a;
|
$text-secondary: #4a4a4a
|
||||||
|
;
|
||||||
$text-muted: #999;
|
$text-muted: #999;
|
||||||
|
|
||||||
$bg-primary: #ffffff;
|
$bg-primary: #ffffff;
|
||||||
@ -30,21 +31,11 @@ $spacing-3xl: 4rem;
|
|||||||
|
|
||||||
// Typography
|
// Typography
|
||||||
|
|
||||||
$font-family-body:
|
$font-family-body: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
"Manrope",
|
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||||
-apple-system,
|
'Helvetica Neue', sans-serif;
|
||||||
BlinkMacSystemFont,
|
|
||||||
"Segoe UI",
|
|
||||||
"Roboto",
|
|
||||||
"Oxygen",
|
|
||||||
"Ubuntu",
|
|
||||||
"Cantarell",
|
|
||||||
"Fira Sans",
|
|
||||||
"Droid Sans",
|
|
||||||
"Helvetica Neue",
|
|
||||||
sans-serif;
|
|
||||||
|
|
||||||
$font-family-heading: "Lora", serif;
|
$font-family-heading: 'Lora', serif;
|
||||||
|
|
||||||
$font-size-xs: 0.75rem;
|
$font-size-xs: 0.75rem;
|
||||||
$font-size-sm: 0.875rem;
|
$font-size-sm: 0.875rem;
|
||||||
@ -81,4 +72,4 @@ $transition-slow: 0.3s ease;
|
|||||||
$breakpoint-sm: 640px;
|
$breakpoint-sm: 640px;
|
||||||
$breakpoint-md: 768px;
|
$breakpoint-md: 768px;
|
||||||
$breakpoint-lg: 1024px;
|
$breakpoint-lg: 1024px;
|
||||||
$breakpoint-xl: 1280px;
|
$breakpoint-xl: 1280px;
|
||||||
@ -1,7 +1,7 @@
|
|||||||
// 1. Configuration & helpers (no CSS output)
|
// 1. Configuration & helpers (no CSS output)
|
||||||
@use "variables";
|
@use 'variables';
|
||||||
@use "mixins";
|
@use 'mixins';
|
||||||
@use "fonts";
|
@use 'fonts';
|
||||||
|
|
||||||
// 2. Global base styles
|
// 2. Global base styles
|
||||||
@use "global";
|
@use 'global';
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from 'react-router-dom';
|
||||||
import "./_Header.scss";
|
import './_Header.scss';
|
||||||
import logo from "../../assets/coopcloud_logo_grey.svg";
|
import logo from '../../assets/coopcloud_logo_grey.svg';
|
||||||
|
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -9,24 +10,26 @@ interface HeaderProps {
|
|||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({ children }) => {
|
export const Header: React.FC<HeaderProps> = ({ children }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return(
|
||||||
<header className="layout-header">
|
<header className="layout-header">
|
||||||
<div className="header-content">
|
<div className="header-content">
|
||||||
<h1 onClick={() => navigate("/dashboard")} className="logo">
|
<h1 onClick={() => navigate('/dashboard')} className="logo">
|
||||||
<img className="logo" src={logo} />
|
<img className="logo" src={logo}/>
|
||||||
</h1>
|
</h1>
|
||||||
<nav className="nav">
|
<nav className="nav">
|
||||||
<button onClick={() => navigate("/apps")} className="nav-link">
|
<button onClick={() => navigate('/dashboard')} className="nav-link">
|
||||||
Apps
|
Dashboard
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => navigate("/servers")} className="nav-link">
|
<button onClick={() => navigate('/apps')} className="nav-link">
|
||||||
Servers
|
Apps
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => navigate("/recipes")} className="nav-link">
|
<button onClick={() => navigate('/servers')} className="nav-link">
|
||||||
Recipes
|
Servers
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
<button onClick={() => navigate('/recipes')} className="nav-link">
|
||||||
</div>
|
Recipes
|
||||||
</header>
|
</button>
|
||||||
);
|
</nav>
|
||||||
};
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
@use "../../assets/scss/variables" as *;
|
@use '../../assets/scss/variables' as *;
|
||||||
@use "../../assets/scss/mixins" as *;
|
@use '../../assets/scss/mixins' as *;
|
||||||
|
|
||||||
.layout-header {
|
.layout-header {
|
||||||
background-color: $primary-light;
|
background-color: $primary-light;
|
||||||
@ -27,14 +27,12 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -65,7 +63,7 @@
|
|||||||
|
|
||||||
// Active indicator
|
// Active indicator
|
||||||
&.active::after {
|
&.active::after {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@ -80,4 +78,4 @@
|
|||||||
font-size: $font-size-xl;
|
font-size: $font-size-xl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import type { LogEntry } from "../../services/mockApi"; // Import from mockApi (or api for real)
|
import type { LogEntry } from '../../services/mockApi'; // Import from mockApi (or api for real)
|
||||||
import "./_Terminal.scss";
|
import './_Terminal.scss';
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
logs: LogEntry[];
|
logs: LogEntry[];
|
||||||
@ -8,44 +8,34 @@ interface TerminalProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Terminal: React.FC<TerminalProps> = ({
|
export const Terminal: React.FC<TerminalProps> = ({ logs, isActive, onClose }) => {
|
||||||
logs,
|
|
||||||
isActive,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const terminalRef = useRef<HTMLDivElement>(null);
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const indexRef = useRef(0);
|
||||||
const [displayedLogs, setDisplayedLogs] = useState<LogEntry[]>([]);
|
const [displayedLogs, setDisplayedLogs] = useState<LogEntry[]>([]);
|
||||||
|
|
||||||
// Stream logs in with delays for realistic effect
|
// Stream logs in with delays for realistic effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive || logs.length === 0) {
|
if (!isActive || logs.length === 0) {
|
||||||
|
indexRef.current = 0;
|
||||||
setDisplayedLogs([]);
|
setDisplayedLogs([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDisplayedLogs([]);
|
|
||||||
let currentIndex = 0;
|
|
||||||
|
|
||||||
const streamLogs = () => {
|
const streamLogs = () => {
|
||||||
if (currentIndex < logs.length) {
|
if (indexRef.current < logs.length) {
|
||||||
setDisplayedLogs((prev) => [...prev, logs[currentIndex]]);
|
setDisplayedLogs(prev => [...prev, logs[indexRef.current]]);
|
||||||
currentIndex++;
|
indexRef.current++;
|
||||||
|
|
||||||
// Variable delay based on log type
|
// Variable delay based on log type
|
||||||
const delay =
|
const delay = logs[indexRef.current - 1]?.type === 'command' ? 200 :
|
||||||
logs[currentIndex - 1]?.type === "command"
|
logs[indexRef.current - 1]?.type === 'output' ? 100 : 300;
|
||||||
? 200
|
|
||||||
: logs[currentIndex - 1]?.type === "output"
|
|
||||||
? 100
|
|
||||||
: 300;
|
|
||||||
|
|
||||||
setTimeout(streamLogs, delay);
|
setTimeout(streamLogs, delay);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
streamLogs();
|
streamLogs();
|
||||||
}, [logs, isActive]);
|
}, [logs, isActive]);
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
// Auto-scroll to bottom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (terminalRef.current) {
|
if (terminalRef.current) {
|
||||||
@ -58,11 +48,11 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (date: Date) => {
|
const formatTime = (date: Date) => {
|
||||||
return date.toLocaleTimeString("en-US", {
|
return date.toLocaleTimeString('en-US', {
|
||||||
hour12: false,
|
hour12: false,
|
||||||
hour: "2-digit",
|
hour: '2-digit',
|
||||||
minute: "2-digit",
|
minute: '2-digit',
|
||||||
second: "2-digit",
|
second: '2-digit'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -77,18 +67,13 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
<div className="terminal-title">abra CLI</div>
|
<div className="terminal-title">abra CLI</div>
|
||||||
<div className="terminal-spacer"></div>
|
<div className="terminal-spacer"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="terminal-content" ref={terminalRef}>
|
<div className="terminal-content" ref={terminalRef}>
|
||||||
{displayedLogs
|
{displayedLogs.filter(log => log && log.type).map((log, index) => (
|
||||||
.filter((log) => log && log.type)
|
<div key={index} className={`terminal-line terminal-${log.type}`}>
|
||||||
.map((log, index) => (
|
<span className="terminal-text">{log.text}</span>
|
||||||
<div key={index} className={`terminal-line terminal-${log.type}`}>
|
</div>
|
||||||
<span className="terminal-timestamp">
|
))}
|
||||||
[{formatTime(log.timestamp)}]
|
|
||||||
</span>
|
|
||||||
<span className="terminal-text">{log.text}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{isActive && displayedLogs.length > 0 && (
|
{isActive && displayedLogs.length > 0 && (
|
||||||
<div className="terminal-cursor">▊</div>
|
<div className="terminal-cursor">▊</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
@use "../../assets/scss/variables" as *;
|
@use '../../assets/scss/variables' as *;
|
||||||
@use "../../assets/scss/mixins" as *;
|
@use '../../assets/scss/mixins' as *;
|
||||||
|
|
||||||
.terminal-container {
|
.terminal-container {
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
font-family:
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
|
||||||
"SF Mono", "Monaco", "Inconsolata", "Fira Code", "Droid Sans Mono",
|
|
||||||
"Source Code Pro", monospace;
|
|
||||||
margin-bottom: $spacing-xl;
|
margin-bottom: $spacing-xl;
|
||||||
animation: terminal-appear 0.3s ease-out;
|
animation: terminal-appear 0.3s ease-out;
|
||||||
}
|
}
|
||||||
@ -183,12 +181,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes terminal-cursor-blink {
|
@keyframes terminal-cursor-blink {
|
||||||
0%,
|
0%, 50% {
|
||||||
50% {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
51%,
|
51%, 100% {
|
||||||
100% {
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -205,4 +201,4 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,24 +1,14 @@
|
|||||||
@use "./assets/scss/variables" as *;
|
@use './assets/scss/variables' as *;
|
||||||
@use "./assets/scss/mixins" as *;
|
@use './assets/scss/mixins' as *;
|
||||||
@use "./assets/scss/global" as *;
|
@use './assets/scss/global' as *;
|
||||||
|
|
||||||
// Global root styles
|
// Global root styles
|
||||||
:root {
|
:root {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-family:
|
font-family: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
"Manrope",
|
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||||
-apple-system,
|
'Helvetica Neue', sans-serif;
|
||||||
BlinkMacSystemFont,
|
|
||||||
"Segoe UI",
|
|
||||||
"Roboto",
|
|
||||||
"Oxygen",
|
|
||||||
"Ubuntu",
|
|
||||||
"Cantarell",
|
|
||||||
"Fira Sans",
|
|
||||||
"Droid Sans",
|
|
||||||
"Helvetica Neue",
|
|
||||||
sans-serif;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
color: $text-primary;
|
color: $text-primary;
|
||||||
@ -54,12 +44,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Global heading styles
|
// Global heading styles
|
||||||
h1,
|
h1, h2, h3, h4, h5, h6 {
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
color: $text-primary;
|
color: $text-primary;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/main.tsx
12
src/main.tsx
@ -1,10 +1,10 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from 'react-dom/client'
|
||||||
import "./index.scss";
|
import './index.scss'
|
||||||
import App from "./App.tsx";
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
)
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
@use "../../assets/scss/variables" as *;
|
@use '../../assets/scss/variables' as *;
|
||||||
@use "../../assets/scss/mixins" as *;
|
@use '../../assets/scss/mixins' as *;
|
||||||
@use "../../assets/scss/global" as *;
|
@use '../../assets/scss/global' as *;
|
||||||
@use "sass:color";
|
|
||||||
|
|
||||||
.app-detail-page {
|
.app-detail-page {
|
||||||
@extend .page-wrapper;
|
@extend .page-wrapper;
|
||||||
@ -48,11 +47,29 @@
|
|||||||
|
|
||||||
// Action buttons
|
// Action buttons
|
||||||
.action-btn {
|
.action-btn {
|
||||||
@include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm);
|
padding: $spacing-sm $spacing-lg;
|
||||||
|
border: 2px solid $border-color;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-base;
|
||||||
|
background: white;
|
||||||
|
color: $text-primary;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
&.primary {
|
&.primary {
|
||||||
background: $primary;
|
background: $primary;
|
||||||
color: $text-primary;
|
color: white;
|
||||||
border-color: $primary;
|
border-color: $primary;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
@ -60,11 +77,20 @@
|
|||||||
border-color: $primary-light;
|
border-color: $primary-light;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
&.service {
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #3fff5c9d;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
&.secondary {
|
&.secondary {
|
||||||
background: $bg-secondary;
|
background: $bg-secondary;
|
||||||
color: $text-primary;
|
color: $text-primary;
|
||||||
border-color: $border-color;
|
border-color: $border-color;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: darken($bg-secondary, 5%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.danger {
|
&.danger {
|
||||||
@ -73,7 +99,7 @@
|
|||||||
border-color: $error;
|
border-color: $error;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: color.adjust($error, $lightness: -10%);
|
background: darken($error, 10%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -111,6 +137,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
.info-grid {
|
.info-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -142,13 +211,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chaos-active {
|
.chaos-active {
|
||||||
background: color.adjust($info, $lightness: -10%);
|
color: darken($info, 10%);
|
||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-medium;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.domain-link {
|
.domain-link {
|
||||||
color: $primary-dark;
|
color: $primary;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: $font-size-base;
|
font-size: $font-size-base;
|
||||||
transition: color $transition-base;
|
transition: color $transition-base;
|
||||||
@ -162,7 +231,7 @@
|
|||||||
.server-link {
|
.server-link {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: $primary-dark;
|
color: $primary;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: $font-size-base;
|
font-size: $font-size-base;
|
||||||
@ -265,7 +334,7 @@
|
|||||||
transition: all $transition-base;
|
transition: all $transition-base;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: color.adjust($warning, $lightness: -10%);
|
background: darken($warning, 10%);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -280,7 +349,7 @@
|
|||||||
.version-latest {
|
.version-latest {
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
background: rgba($success, 0.1);
|
background: rgba($success, 0.1);
|
||||||
background: color.adjust($success, $lightness: -10%);
|
color: darken($success, 10%);
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: $font-size-base;
|
font-size: $font-size-base;
|
||||||
@ -326,7 +395,6 @@
|
|||||||
background: rgba($error, 0.05);
|
background: rgba($error, 0.05);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-text {
|
.action-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-medium;
|
||||||
|
|||||||
@ -1,115 +1,200 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Header } from "../../components/Header/Header";
|
import { Header } from '../../components/Header/Header';
|
||||||
import { Terminal } from "../../components/Terminal/Terminal";
|
import { Terminal } from '../../components/Terminal/Terminal';
|
||||||
import { apiService } from "../../services/api";
|
import { apiService } from '../../services/api';
|
||||||
import type { AbraApp } from "../../types";
|
import type { AbraApp, AbraAppService, AbraServiceState } from '../../types';
|
||||||
import type { LogEntry } from "../../services/mockApi";
|
import type { LogEntry } from '../../services/mockApi';
|
||||||
import "./App.scss";
|
import './App.scss';
|
||||||
|
|
||||||
export const AppDetail: React.FC = () => {
|
export const AppDetail: React.FC = () => {
|
||||||
const { server, appName } = useParams<{ server: string; appName: string }>();
|
const { server, appName } = useParams<{ server: string; appName: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [app, setApp] = useState<AbraApp | null>(null);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState('');
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
// Terminal state
|
// Terminal state
|
||||||
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
|
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
|
||||||
const [terminalActive, setTerminalActive] = useState(false);
|
const [terminalActive, setTerminalActive] = useState(false);
|
||||||
|
|
||||||
const isMockMode = import.meta.env.VITE_MOCK_AUTH === "true";
|
// Stream state
|
||||||
|
const stopRef = useRef<null | (() => void)>(null);
|
||||||
|
const deployRef = useRef<null | (() => void)>(null);
|
||||||
|
|
||||||
|
// Use to refresh page
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const isMockMode = false;
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchApp = async () => {
|
const fetchApp = async () => {
|
||||||
try {
|
try {
|
||||||
if (isMockMode) {
|
if (isMockMode) {
|
||||||
const { mockApiService } = await import("../../services/mockApi");
|
const { mockApiService } = await import('../../services/mockApi');
|
||||||
const appsData = await mockApiService.getAppsGrouped();
|
const appsData = await mockApiService.getAppsGrouped();
|
||||||
|
|
||||||
const serverApps = appsData[server || ""];
|
const serverApps = appsData[server || ''];
|
||||||
const foundApp = serverApps?.apps.find((a) => a.appName === appName);
|
const foundApp = serverApps?.apps.find(a => a.appName === appName);
|
||||||
|
|
||||||
if (foundApp) {
|
if (foundApp) {
|
||||||
setApp(foundApp);
|
setApp(foundApp);
|
||||||
} else {
|
} else {
|
||||||
setError("App not found");
|
setError('App not found');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
console.log('fetching app...');
|
||||||
const appsData = await apiService.getAppsGrouped();
|
const appsData = await apiService.getAppsGrouped();
|
||||||
const serverApps = appsData[server || ""];
|
const serverApps = appsData[server || ''];
|
||||||
const foundApp = serverApps?.apps.find((a) => a.appName === appName);
|
const foundApp = serverApps?.apps.find(a => a.appName === appName);
|
||||||
|
|
||||||
if (foundApp) {
|
if (foundApp) {
|
||||||
setApp(foundApp);
|
setApp(foundApp);
|
||||||
|
// 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 {
|
} else {
|
||||||
setError("App not found");
|
setError('App not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load app");
|
setError(err instanceof Error ? err.message : 'Failed to load app');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchApp();
|
fetchApp();
|
||||||
}, [server, appName, isMockMode]);
|
}, [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]);
|
||||||
const handleAction = async (action: string, version?: string) => {
|
const handleAction = async (action: string, version?: string) => {
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
|
|
||||||
setActionLoading(action);
|
setActionLoading(action);
|
||||||
setTerminalActive(true);
|
|
||||||
setTerminalLogs([]);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isMockMode) {
|
if (isMockMode) {
|
||||||
const { mockApiService } = await import("../../services/mockApi");
|
const { mockApiService } = await import('../../services/mockApi');
|
||||||
|
|
||||||
const onLog = (log: LogEntry) => {
|
const onLog = (log: LogEntry) => {
|
||||||
setTerminalLogs((prev) => [...prev, log]);
|
setTerminalLogs(prev => [...prev, log]);
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "deploy":
|
case 'deploy':
|
||||||
await mockApiService.deployApp(app.appName, onLog);
|
await mockApiService.deployApp(app.appName, onLog);
|
||||||
break;
|
break;
|
||||||
case "stop":
|
case 'stop':
|
||||||
await mockApiService.stopApp(app.appName, onLog);
|
await mockApiService.stopApp(app.appName, onLog);
|
||||||
break;
|
break;
|
||||||
case "upgrade":
|
case 'upgrade':
|
||||||
if (version) {
|
if (version) {
|
||||||
await mockApiService.upgradeApp(app.appName, version, onLog);
|
await mockApiService.upgradeApp(app.appName, version, onLog);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "remove":
|
case 'remove':
|
||||||
await mockApiService.removeApp(app.appName, onLog);
|
await mockApiService.removeApp(app.appName, onLog);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Real API calls
|
// Real API calls
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "stop":
|
case 'stop':
|
||||||
await apiService.stopApp(app.appName);
|
await apiService.stopApp(app.appName);
|
||||||
|
setRefreshKey(prev => prev + 1);
|
||||||
break;
|
break;
|
||||||
case "deploy":
|
case 'deploy':
|
||||||
await apiService.deployApp(app.appName);
|
await apiService.deployApp(app.appName);
|
||||||
|
setDeployState("deploying");
|
||||||
|
console.log("deploying");
|
||||||
|
deployRef.current = apiService.deployLogs(app.appName,
|
||||||
|
(update) => {
|
||||||
|
if (update.type === "service") {
|
||||||
|
console.log(update.data.name)
|
||||||
|
const serviceName = update.data.name.slice(app.appName.length+1)
|
||||||
|
setServiceState(prev => ({
|
||||||
|
...prev,
|
||||||
|
[serviceName]: {
|
||||||
|
...prev[serviceName] ?? {},
|
||||||
|
...update.data
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (update.type === "done") {
|
||||||
|
console.log("done?");
|
||||||
|
console.log(update.data.failed, update.data.count);
|
||||||
|
if (update.data.failed) {
|
||||||
|
setDeployState('failed');
|
||||||
|
console.log("Deploy failed?");
|
||||||
|
} else {
|
||||||
|
setDeployState("deployed");
|
||||||
|
}
|
||||||
|
setRefreshKey(prev => prev + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
break;
|
break;
|
||||||
|
case 'logs':
|
||||||
|
setTerminalActive(true);
|
||||||
|
setTerminalLogs([]);
|
||||||
|
if (version) {
|
||||||
|
stopRef.current = apiService.getLogs(app.appName, version,
|
||||||
|
(line) => {
|
||||||
|
console.log(line);
|
||||||
|
setTerminalLogs(prev => [...prev, {
|
||||||
|
type: 'info',
|
||||||
|
text: `${line}`,
|
||||||
|
timestamp: new Date()
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Action failed:", err);
|
console.error('Action failed:', err);
|
||||||
setTerminalLogs((prev) => [
|
setTerminalLogs(prev => [...prev, {
|
||||||
...prev,
|
type: 'error',
|
||||||
{
|
text: `❌ Error: ${err instanceof Error ? err.message : 'Action failed'}`,
|
||||||
type: "error",
|
timestamp: new Date()
|
||||||
text: `❌ Error: ${err instanceof Error ? err.message : "Action failed"}`,
|
}]);
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@ -131,24 +216,23 @@ export const AppDetail: React.FC = () => {
|
|||||||
<div className="app-detail-page">
|
<div className="app-detail-page">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="app-detail-content">
|
<main className="app-detail-content">
|
||||||
<div className="error">{error || "App not found"}</div>
|
<div className="error">{error || 'App not found'}</div>
|
||||||
<button onClick={() => navigate("/apps")} className="back-button">
|
<button onClick={() => navigate('/apps')} className="back-button">
|
||||||
Back to Apps
|
Back to Apps
|
||||||
</button>
|
</button>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// TODO: make sure this makes sense when app.upgrade is unknown
|
||||||
const upgradeVersions =
|
const upgradeVersions = (app.upgrade !== 'latest' && app.upgrade !== 'unknown') ? app.upgrade.split('\n') : [];
|
||||||
app.upgrade !== "latest" ? app.upgrade.split("\n") : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-detail-page">
|
<div className="app-detail-page">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="app-detail-content">
|
<main className="app-detail-content">
|
||||||
<div className="breadcrumb">
|
<div className="breadcrumb">
|
||||||
<button onClick={() => navigate("/apps")} className="breadcrumb-link">
|
<button onClick={() => navigate('/apps')} className="breadcrumb-link">
|
||||||
Apps
|
Apps
|
||||||
</button>
|
</button>
|
||||||
<span className="breadcrumb-separator">/</span>
|
<span className="breadcrumb-separator">/</span>
|
||||||
@ -160,41 +244,40 @@ export const AppDetail: React.FC = () => {
|
|||||||
<h1>{app.appName}</h1>
|
<h1>{app.appName}</h1>
|
||||||
<div className="app-meta">
|
<div className="app-meta">
|
||||||
<span className="recipe-badge">{app.recipe}</span>
|
<span className="recipe-badge">{app.recipe}</span>
|
||||||
<span className={`status-badge status-${app.status}`}>
|
<span className={`status-badge status-${app.status}`}>{app.status}</span>
|
||||||
{app.status}
|
{app.chaos === 'true' && (
|
||||||
</span>
|
<span className="chaos-badge" title="Chaos mode enabled">🔬 Chaos</span>
|
||||||
{app.chaos === "true" && (
|
|
||||||
<span className="chaos-badge" title="Chaos mode enabled">
|
|
||||||
{" "}
|
|
||||||
Chaos
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="app-actions">
|
<div className="app-actions">
|
||||||
<button
|
<button
|
||||||
className="action-btn danger"
|
className="action-btn danger"
|
||||||
onClick={() => handleAction("stop")}
|
onClick={() => handleAction('stop')}
|
||||||
disabled={!!actionLoading}
|
disabled={!!actionLoading}
|
||||||
>
|
>
|
||||||
{actionLoading === "stop" ? "Stopping..." : "Stop"}
|
{actionLoading === 'stop' ? 'Stopping...' : 'Stop'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="action-btn primary"
|
className="action-btn primary"
|
||||||
onClick={() => handleAction("deploy")}
|
onClick={() => handleAction('deploy')}
|
||||||
disabled={!!actionLoading}
|
disabled={!!actionLoading}
|
||||||
>
|
>
|
||||||
{actionLoading === "deploy" ? "Deploying..." : "Deploy"}
|
{actionLoading === 'deploy' ? 'Deploying...' : 'Deploy'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terminal Component */}
|
{/* Terminal Component */}
|
||||||
<Terminal
|
<Terminal
|
||||||
logs={terminalLogs}
|
logs={terminalLogs}
|
||||||
isActive={terminalActive}
|
isActive={terminalActive}
|
||||||
onClose={() => setTerminalActive(false)}
|
onClose={() => {
|
||||||
|
stopRef.current?.();
|
||||||
|
stopRef.current = null;
|
||||||
|
setTerminalActive(false)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="content-grid">
|
<div className="content-grid">
|
||||||
@ -202,7 +285,7 @@ export const AppDetail: React.FC = () => {
|
|||||||
<div className="main-column">
|
<div className="main-column">
|
||||||
<section className="info-card">
|
<section className="info-card">
|
||||||
<h2>Application Details</h2>
|
<h2>Application Details</h2>
|
||||||
|
|
||||||
<div className="info-grid">
|
<div className="info-grid">
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<label>App Name</label>
|
<label>App Name</label>
|
||||||
@ -212,12 +295,7 @@ export const AppDetail: React.FC = () => {
|
|||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<label>Domain</label>
|
<label>Domain</label>
|
||||||
{app.domain ? (
|
{app.domain ? (
|
||||||
<a
|
<a href={`https://${app.domain}`} target="_blank" rel="noopener noreferrer" className="domain-link">
|
||||||
href={`https://${app.domain}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="link"
|
|
||||||
>
|
|
||||||
{app.domain} ↗
|
{app.domain} ↗
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
@ -226,14 +304,13 @@ export const AppDetail: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<label htmlFor="server-link">Server</label>
|
<label>Server</label>
|
||||||
<Link
|
<button
|
||||||
id="server-link"
|
onClick={() => navigate(`/servers/${app.server}`)}
|
||||||
to={`/servers/${app.server}`}
|
className="server-link"
|
||||||
className="link"
|
|
||||||
>
|
>
|
||||||
{app.server} ↗
|
{app.server} →
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
@ -250,23 +327,86 @@ export const AppDetail: React.FC = () => {
|
|||||||
|
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<label>Chaos Mode</label>
|
<label>Chaos Mode</label>
|
||||||
<span className={app.chaos === "true" ? "chaos-active" : ""}>
|
<span className={app.chaos === 'true' ? 'chaos-active' : ''}>
|
||||||
{app.chaos === "true" ? "☠️ Enabled" : "Disabled"}
|
{app.chaos === 'true' ? '🔬 Enabled' : 'Disabled'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="info-card">
|
||||||
<h2>Version Information</h2>
|
<h2>Version Information</h2>
|
||||||
|
|
||||||
<div className="version-info">
|
<div className="version-info">
|
||||||
<div className="version-current">
|
<div className="version-current">
|
||||||
<label>Current Version</label>
|
<label>Current Version</label>
|
||||||
<code>{app.version}</code>
|
<code>{app.version}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{app.chaosVersion !== "unknown" && (
|
{app.chaosVersion !== 'unknown' && (
|
||||||
<div className="version-current">
|
<div className="version-current">
|
||||||
<label>Chaos Version</label>
|
<label>Chaos Version</label>
|
||||||
<code>{app.chaosVersion}</code>
|
<code>{app.chaosVersion}</code>
|
||||||
@ -277,22 +417,18 @@ export const AppDetail: React.FC = () => {
|
|||||||
<div className="version-upgrades">
|
<div className="version-upgrades">
|
||||||
<label>
|
<label>
|
||||||
Available Upgrades
|
Available Upgrades
|
||||||
<span className="upgrade-count">
|
<span className="upgrade-count">{upgradeVersions.length}</span>
|
||||||
{upgradeVersions.length}
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div className="upgrade-list">
|
<div className="upgrade-list">
|
||||||
{upgradeVersions.map((version, idx) => (
|
{upgradeVersions.map((version, idx) => (
|
||||||
<div key={idx} className="upgrade-item">
|
<div key={idx} className="upgrade-item">
|
||||||
<code>{version}</code>
|
<code>{version}</code>
|
||||||
<button
|
<button
|
||||||
className="upgrade-btn"
|
className="upgrade-btn"
|
||||||
onClick={() => handleAction("upgrade", version)}
|
onClick={() => handleAction('upgrade', version)}
|
||||||
disabled={!!actionLoading}
|
disabled={!!actionLoading}
|
||||||
>
|
>
|
||||||
{actionLoading === "upgrade"
|
{actionLoading === 'upgrade' ? 'Upgrading...' : 'Upgrade'}
|
||||||
? "Upgrading..."
|
|
||||||
: "Upgrade"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -300,8 +436,22 @@ export const AppDetail: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{app.upgrade === "latest" && (
|
{app.upgrade === 'unknown' && (
|
||||||
<div className="version-latest">✓ Running latest version</div>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -311,31 +461,30 @@ export const AppDetail: React.FC = () => {
|
|||||||
<div className="sidebar-column">
|
<div className="sidebar-column">
|
||||||
<section className="info-card">
|
<section className="info-card">
|
||||||
<h2>Quick Actions</h2>
|
<h2>Quick Actions</h2>
|
||||||
|
|
||||||
<div className="action-list">
|
<div className="action-list">
|
||||||
<button
|
|
||||||
|
<button
|
||||||
className="action-list-item"
|
className="action-list-item"
|
||||||
onClick={() => handleAction("deploy")}
|
onClick={() => handleAction('deploy')}
|
||||||
disabled={!!actionLoading}
|
disabled={!!actionLoading}
|
||||||
>
|
>
|
||||||
<span className="action-text">Deploy Application</span>
|
<span className="action-text">Deploy Application</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="action-list-item"
|
className="action-list-item"
|
||||||
onClick={() => handleAction("stop")}
|
onClick={() => handleAction('stop')}
|
||||||
disabled={!!actionLoading}
|
disabled={!!actionLoading}
|
||||||
>
|
>
|
||||||
<span className="action-text">Stop Application</span>
|
<span className="action-text">Stop Application</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="action-list-item danger"
|
className="action-list-item danger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
if (confirm(`Are you sure you want to remove ${app.appName}?`)) {
|
||||||
confirm(`Are you sure you want to remove ${app.appName}?`)
|
handleAction('remove');
|
||||||
) {
|
|
||||||
handleAction("remove");
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!!actionLoading}
|
disabled={!!actionLoading}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
@use "../../assets/scss/variables" as *;
|
@use '../../assets/scss/variables' as *;
|
||||||
@use "../../assets/scss/mixins" as *;
|
@use '../../assets/scss/mixins' as *;
|
||||||
@use "../../assets/scss/global" as *;
|
@use '../../assets/scss/global' as *;
|
||||||
|
|
||||||
// Extend global page wrapper
|
// Extend global page wrapper
|
||||||
.apps-page {
|
.apps-page {
|
||||||
@ -11,44 +11,10 @@
|
|||||||
@extend .page-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 specific styles
|
||||||
.apps-table-container {
|
.apps-table-container {
|
||||||
@include scrollable-card;
|
@include card;
|
||||||
|
overflow-x: auto;
|
||||||
margin-bottom: $spacing-lg;
|
margin-bottom: $spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,15 +71,16 @@
|
|||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-medium;
|
||||||
color: $text-primary;
|
color: $text-primary;
|
||||||
}
|
}
|
||||||
.domain-link {
|
}
|
||||||
color: $primary-dark;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color $transition-base;
|
|
||||||
|
|
||||||
&:hover {
|
.domain-link {
|
||||||
color: $primary-light;
|
color: $primary;
|
||||||
text-decoration: underline;
|
text-decoration: none;
|
||||||
}
|
transition: color $transition-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-light;
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,27 +112,23 @@
|
|||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
@include action-btn(
|
background: none;
|
||||||
1px,
|
border: 1px solid $border-color;
|
||||||
$radius-sm,
|
padding: $spacing-xs $spacing-sm;
|
||||||
$spacing-xs $spacing-md,
|
border-radius: $radius-sm;
|
||||||
$font-size-sm
|
cursor: pointer;
|
||||||
);
|
font-size: $font-size-base;
|
||||||
|
color: $text-primary;
|
||||||
|
transition: all $transition-base;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $primary;
|
background-color: $bg-tertiary;
|
||||||
color: white;
|
transform: scale(1.1);
|
||||||
border-color: $primary;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.upgrade {
|
&.upgrade {
|
||||||
border-color: $warning;
|
border-color: $warning;
|
||||||
color: $warning;
|
color: $warning;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $warning;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,27 @@
|
|||||||
// TODOS:
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
// make the two filters non-exlusive
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Header } from '../../components/Header/Header';
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import { apiService } from '../../services/api';
|
||||||
import { useNavigate } from "react-router-dom";
|
import type { AbraApp, AppWithServer, ServerAppsResponse } from '../../types';
|
||||||
import { Header } from "../../components/Header/Header";
|
import './Apps.scss';
|
||||||
import { apiService } from "../../services/api";
|
|
||||||
import type { AbraApp, AppWithServer, ServerAppsResponse } from "../../types";
|
|
||||||
import "./Apps.scss";
|
|
||||||
|
|
||||||
export const Apps: React.FC = () => {
|
export const Apps: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [appsData, setAppsData] = useState<ServerAppsResponse | null>(null);
|
const [appsData, setAppsData] = useState<ServerAppsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState('');
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [filterServer, setFilterServer] = useState<string>("all");
|
const [filterServer, setFilterServer] = useState<string>('all');
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||||
const [showUpgradesOnly, setShowUpgradesOnly] = useState(false);
|
const [showUpgradesOnly, setShowUpgradesOnly] = useState(false);
|
||||||
const [showChaosOnly, setShowChaosOnly] = useState(false);
|
|
||||||
|
|
||||||
const isMockMode = import.meta.env.VITE_MOCK_AUTH === "true";
|
const isMockMode = false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
if (isMockMode) {
|
if (isMockMode) {
|
||||||
const { mockApiService } = await import("../../services/mockApi");
|
const { mockApiService } = await import('../../services/mockApi');
|
||||||
const data = await mockApiService.getAppsGrouped();
|
const data = await mockApiService.getAppsGrouped();
|
||||||
setAppsData(data);
|
setAppsData(data);
|
||||||
} else {
|
} else {
|
||||||
@ -32,7 +29,7 @@ export const Apps: React.FC = () => {
|
|||||||
setAppsData(data);
|
setAppsData(data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load apps");
|
setError(err instanceof Error ? err.message : 'Failed to load apps');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -44,15 +41,15 @@ export const Apps: React.FC = () => {
|
|||||||
// Flatten and enrich apps data
|
// Flatten and enrich apps data
|
||||||
const allApps: AppWithServer[] = useMemo(() => {
|
const allApps: AppWithServer[] = useMemo(() => {
|
||||||
if (!appsData) return [];
|
if (!appsData) return [];
|
||||||
|
|
||||||
return Object.entries(appsData).flatMap(([serverName, serverData]) =>
|
return Object.entries(appsData).flatMap(([serverName, serverData]) =>
|
||||||
serverData.apps.map((app) => ({
|
serverData.apps.map(app => ({
|
||||||
...app,
|
...app,
|
||||||
serverStats: {
|
serverStats: {
|
||||||
appCount: serverData.appCount,
|
appCount: serverData.appCount,
|
||||||
upgradeCount: serverData.upgradeCount,
|
upgradeCount: serverData.upgradeCount,
|
||||||
},
|
},
|
||||||
})),
|
}))
|
||||||
);
|
);
|
||||||
}, [appsData]);
|
}, [appsData]);
|
||||||
|
|
||||||
@ -62,37 +59,33 @@ export const Apps: React.FC = () => {
|
|||||||
return Object.keys(appsData);
|
return Object.keys(appsData);
|
||||||
}, [appsData]);
|
}, [appsData]);
|
||||||
|
|
||||||
// Filter apps (additive filters: upgrades AND chaos can be applied together)
|
// Filter apps
|
||||||
const filteredApps = useMemo(() => {
|
const filteredApps = useMemo(() => {
|
||||||
return allApps.filter((app) => {
|
return allApps.filter(app => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
app.appName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
app.appName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
app.recipe.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
app.recipe.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
(app.domain || "").toLowerCase().includes(searchTerm.toLowerCase());
|
(app.domain || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
const matchesServer =
|
const matchesServer = filterServer === 'all' || app.server === filterServer;
|
||||||
filterServer === "all" || app.server === filterServer;
|
const matchesChaos = filterStatus === 'all' ||
|
||||||
const matchesChaos = !showChaosOnly || app.chaos === "true";
|
(filterStatus === 'chaos' && app.chaos === 'true') ||
|
||||||
const matchesUpgrade = !showUpgradesOnly || app.upgrade !== "latest";
|
(filterStatus === 'stable' && app.chaos === 'false');
|
||||||
|
const matchesUpgrade = !showUpgradesOnly || app.upgrade !== 'latest';
|
||||||
|
|
||||||
return matchesSearch && matchesServer && matchesChaos && matchesUpgrade;
|
return matchesSearch && matchesServer && matchesChaos && matchesUpgrade;
|
||||||
});
|
});
|
||||||
}, [allApps, searchTerm, filterServer, showUpgradesOnly, showChaosOnly]);
|
}, [allApps, searchTerm, filterServer, filterStatus, showUpgradesOnly]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const total = allApps.length;
|
const total = allApps.length;
|
||||||
const needsUpgrade = allApps.filter(
|
const needsUpgrade = allApps.filter(app => app.upgrade !== 'latest').length;
|
||||||
(app) => app.upgrade !== "latest",
|
const chaosApps = allApps.filter(app => app.chaos === 'true').length;
|
||||||
).length;
|
|
||||||
const chaosApps = allApps.filter((app) => app.chaos === "true").length;
|
|
||||||
const totalServers = servers.length;
|
const totalServers = servers.length;
|
||||||
|
|
||||||
return { total, needsUpgrade, chaosApps, totalServers };
|
return { total, needsUpgrade, chaosApps, totalServers };
|
||||||
}, [allApps, servers]);
|
}, [allApps, servers]);
|
||||||
|
|
||||||
const toggleUpgrades = () => setShowUpgradesOnly((prev) => !prev);
|
|
||||||
const toggleChaos = () => setShowChaosOnly((prev) => !prev);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="apps-page">
|
<div className="apps-page">
|
||||||
@ -121,41 +114,35 @@ export const Apps: React.FC = () => {
|
|||||||
<main className="apps-content">
|
<main className="apps-content">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Applications</h1>
|
<h1>Applications</h1>
|
||||||
<p className="subtitle">
|
<p className="subtitle">{stats.total} apps across {stats.totalServers} servers</p>
|
||||||
{stats.total} apps across {stats.totalServers} servers
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compact Stats Overview */}
|
{/* Stats Overview */}
|
||||||
<div className="stats-row">
|
<div className="stats-grid">
|
||||||
<button
|
<div className="stat-card">
|
||||||
className="stat-chip"
|
<div className="stat-info">
|
||||||
onClick={() => navigate("/servers")}
|
<p className="stat-number">{stats.total}</p>
|
||||||
title="View servers"
|
<p className="stat-label">Total Apps</p>
|
||||||
>
|
</div>
|
||||||
<span className="stat-label">Servers</span>
|
</div>
|
||||||
<span className="stat-value">{stats.totalServers}</span>
|
<div className="stat-card upgrade">
|
||||||
</button>
|
<div className="stat-info">
|
||||||
|
<p className="stat-number">{stats.needsUpgrade}</p>
|
||||||
<button
|
<p className="stat-label">Upgrades Available</p>
|
||||||
className={`stat-chip filter-chip ${showUpgradesOnly ? "active" : ""}`}
|
</div>
|
||||||
onClick={toggleUpgrades}
|
</div>
|
||||||
title="Click to toggle apps with upgrades available"
|
<div className="stat-card chaos">
|
||||||
disabled={stats.needsUpgrade === 0}
|
<div className="stat-info">
|
||||||
>
|
<p className="stat-number">{stats.chaosApps}</p>
|
||||||
<span className="stat-label">Upgrades</span>
|
<p className="stat-label">Chaos Mode</p>
|
||||||
<span className="stat-value">{stats.needsUpgrade}</span>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
<button
|
<div className="stat-info">
|
||||||
className={`stat-chip filter-chip ${showChaosOnly ? "active" : ""}`}
|
<p className="stat-number">{stats.totalServers}</p>
|
||||||
onClick={toggleChaos}
|
<p className="stat-label">Servers</p>
|
||||||
title="Click to toggle chaos mode apps"
|
</div>
|
||||||
disabled={stats.chaosApps === 0}
|
</div>
|
||||||
>
|
|
||||||
<span className="stat-label">Chaos</span>
|
|
||||||
<span className="stat-value">{stats.chaosApps}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@ -168,17 +155,27 @@ export const Apps: React.FC = () => {
|
|||||||
className="search-input"
|
className="search-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<select
|
<select value={filterServer} onChange={(e) => setFilterServer(e.target.value)}>
|
||||||
value={filterServer}
|
|
||||||
onChange={(e) => setFilterServer(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="all">All Servers</option>
|
<option value="all">All Servers</option>
|
||||||
{servers.map((server) => (
|
{servers.map(server => (
|
||||||
<option key={server} value={server}>
|
<option key={server} value={server}>{server}</option>
|
||||||
{server}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="stable">Stable</option>
|
||||||
|
<option value="chaos">Chaos Mode</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="checkbox-filter">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showUpgradesOnly}
|
||||||
|
onChange={(e) => setShowUpgradesOnly(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Show only apps with upgrades</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Apps Table */}
|
{/* Apps Table */}
|
||||||
@ -192,7 +189,7 @@ export const Apps: React.FC = () => {
|
|||||||
<th>Server</th>
|
<th>Server</th>
|
||||||
<th>Version</th>
|
<th>Version</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
{/* <th>Actions</th> */}
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -204,12 +201,10 @@ export const Apps: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredApps.map((app) => (
|
filteredApps.map((app) => (
|
||||||
<tr
|
<tr
|
||||||
key={`${app.server}/${app.appName}`}
|
key={`${app.server}/${app.appName}`}
|
||||||
onClick={() =>
|
onClick={() => navigate(`/apps/${app.server}/${app.appName}`)}
|
||||||
navigate(`/apps/${app.server}/${app.appName}`)
|
style={{ cursor: 'pointer' }}
|
||||||
}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
>
|
>
|
||||||
<td className="app-name-cell">
|
<td className="app-name-cell">
|
||||||
<span className="app-name">{app.appName}</span>
|
<span className="app-name">{app.appName}</span>
|
||||||
@ -219,10 +214,10 @@ export const Apps: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{app.domain ? (
|
{app.domain ? (
|
||||||
<a
|
<a
|
||||||
href={`https://${app.domain}`}
|
href={`https://${app.domain}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="domain-link"
|
className="domain-link"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@ -238,17 +233,12 @@ export const Apps: React.FC = () => {
|
|||||||
<td>
|
<td>
|
||||||
<div className="version-cell">
|
<div className="version-cell">
|
||||||
<span className="version">{app.version}</span>
|
<span className="version">{app.version}</span>
|
||||||
{app.chaos === "true" && (
|
{app.chaos === 'true' && (
|
||||||
<span
|
<span className="chaos-badge" title="Chaos mode enabled"></span>
|
||||||
className="chaos-badge"
|
|
||||||
title="Chaos mode enabled"
|
|
||||||
></span>
|
|
||||||
)}
|
)}
|
||||||
{app.upgrade !== "latest" && (
|
{app.upgrade !== 'latest' && (
|
||||||
<span
|
<span className="upgrade-available" title="Upgrade available">
|
||||||
className="upgrade-available"
|
</span>
|
||||||
title="Upgrade available"
|
|
||||||
></span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -257,7 +247,7 @@ export const Apps: React.FC = () => {
|
|||||||
{app.status}
|
{app.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{/* <td>
|
<td>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button
|
<button
|
||||||
className="action-btn"
|
className="action-btn"
|
||||||
@ -282,7 +272,7 @@ export const Apps: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td> */}
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@ -296,4 +286,4 @@ export const Apps: React.FC = () => {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -1,34 +1,32 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Header } from "../../components/Header/Header";
|
import { Header } from '../../components/Header/Header';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { apiService } from "../../services/api";
|
import { apiService } from '../../services/api';
|
||||||
import type { AbraApp, AbraServer } from "../../types";
|
import type { AbraApp, AbraServer } from '../../types';
|
||||||
import "./_Dashboard.scss";
|
import './_Dashboard.scss';
|
||||||
|
|
||||||
export const Dashboard: React.FC = () => {
|
export const Dashboard: React.FC = () => {
|
||||||
const [apps, setApps] = useState<AbraApp[]>([]);
|
const [apps, setApps] = useState<AbraApp[]>([]);
|
||||||
const [servers, setServers] = useState<AbraServer[]>([]);
|
const [servers, setServers] = useState<AbraServer[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState('');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const isMockMode = import.meta.env.VITE_MOCK_AUTH === "true";
|
const isMockMode = false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
if (isMockMode) {
|
if (isMockMode) {
|
||||||
// Use mock API in development
|
// Use mock API in development
|
||||||
const { mockApiService } = await import("../../services/mockApi");
|
const { mockApiService } = await import('../../services/mockApi');
|
||||||
const [appsData, serversData] = await Promise.all([
|
const [appsData, serversData] = await Promise.all([
|
||||||
mockApiService.getAppsGrouped(),
|
mockApiService.getAppsGrouped(),
|
||||||
mockApiService.getServers(),
|
mockApiService.getServers(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Flatten the grouped apps data
|
// Flatten the grouped apps data
|
||||||
const flatApps = Object.values(appsData).flatMap(
|
const flatApps = Object.values(appsData).flatMap(serverData => serverData.apps);
|
||||||
(serverData) => serverData.apps,
|
|
||||||
);
|
|
||||||
setApps(flatApps);
|
setApps(flatApps);
|
||||||
setServers(serversData);
|
setServers(serversData);
|
||||||
} else {
|
} else {
|
||||||
@ -37,16 +35,14 @@ export const Dashboard: React.FC = () => {
|
|||||||
apiService.getAppsGrouped(),
|
apiService.getAppsGrouped(),
|
||||||
apiService.getServers(),
|
apiService.getServers(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Flatten the grouped apps data
|
// Flatten the grouped apps data
|
||||||
const flatApps = Object.values(appsData).flatMap(
|
const flatApps = Object.values(appsData).flatMap(serverData => serverData.apps);
|
||||||
(serverData) => serverData.apps,
|
|
||||||
);
|
|
||||||
setApps(flatApps);
|
setApps(flatApps);
|
||||||
setServers(serversData);
|
setServers(serversData);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load data");
|
setError(err instanceof Error ? err.message : 'Failed to load data');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -56,8 +52,8 @@ export const Dashboard: React.FC = () => {
|
|||||||
}, [isMockMode]);
|
}, [isMockMode]);
|
||||||
|
|
||||||
// Calculate stats
|
// Calculate stats
|
||||||
const deployedAppsCount = apps.filter((a) => a.status === "deployed").length;
|
const deployedAppsCount = apps.filter(a => a.status === 'deployed').length;
|
||||||
const serversWithAppsCount = new Set(apps.map((a) => a.server)).size;
|
const serversWithAppsCount = new Set(apps.map(a => a.server)).size;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -88,25 +84,25 @@ export const Dashboard: React.FC = () => {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stats-grid">
|
<div className="stats-grid">
|
||||||
<button
|
<button onClick={() => navigate('/apps')} className="nav-link bland-button">
|
||||||
onClick={() => navigate("/apps")}
|
|
||||||
className="nav-link bland-button"
|
|
||||||
>
|
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<h3>Apps</h3>
|
<h3>Apps</h3>
|
||||||
<p className="stat-label">{deployedAppsCount} deployed</p>
|
<p className="stat-number">{apps.length}</p>
|
||||||
|
<p className="stat-label">
|
||||||
|
{deployedAppsCount} deployed
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button onClick={() => navigate('/servers')} className="nav-link bland-button">
|
||||||
onClick={() => navigate("/servers")}
|
|
||||||
className="nav-link bland-button"
|
|
||||||
>
|
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<h3>Servers</h3>
|
<h3>Servers</h3>
|
||||||
<p className="stat-label">{serversWithAppsCount} connected</p>
|
<p className="stat-number">{servers.length}</p>
|
||||||
|
<p className="stat-label">
|
||||||
|
{serversWithAppsCount} connected
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -115,14 +111,14 @@ export const Dashboard: React.FC = () => {
|
|||||||
<h3>Recent Applications</h3>
|
<h3>Recent Applications</h3>
|
||||||
<div className="apps-list">
|
<div className="apps-list">
|
||||||
{apps.slice(0, 5).map((app, index) => (
|
{apps.slice(0, 5).map((app, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${app.server}-${app.appName}-${index}`}
|
key={`${app.server}-${app.appName}-${index}`}
|
||||||
className="app-item"
|
className="app-item"
|
||||||
onClick={() => navigate(`/apps/${app.server}/${app.appName}`)}
|
onClick={() => navigate(`/apps/${app.server}/${app.appName}`)}
|
||||||
>
|
>
|
||||||
<div className="app-info">
|
<div className="app-info">
|
||||||
<h4>{app.appName}</h4>
|
<h4>{app.appName}</h4>
|
||||||
<p className="app-domain">{app.domain || "No domain"}</p>
|
<p className="app-domain">{app.domain || 'No domain'}</p>
|
||||||
<p className="app-server">{app.server}</p>
|
<p className="app-server">{app.server}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`status-badge status-${app.status}`}>
|
<span className={`status-badge status-${app.status}`}>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
@use "../../assets/scss/variables" as *;
|
@use '../../assets/scss/variables' as *;
|
||||||
@use "../../assets/scss/mixins" as *;
|
@use '../../assets/scss/mixins' as *;
|
||||||
@use "../../assets/scss/global" as *;
|
@use '../../assets/scss/global' as *;
|
||||||
|
|
||||||
// Extend global page wrapper
|
// Extend global page wrapper
|
||||||
.dashboard-page {
|
.dashboard-page {
|
||||||
@ -23,21 +23,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.apps-list {
|
.apps-list {
|
||||||
@include card-list-vertical;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-item {
|
.app-item {
|
||||||
@include card;
|
@include card;
|
||||||
@include card-hover-lift(-2px, $shadow-lg);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
transition: transform $transition-base, box-shadow $transition-base;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
.app-info {
|
.app-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
|
margin: 0 0 $spacing-xs;
|
||||||
font-size: $font-size-lg;
|
font-size: $font-size-lg;
|
||||||
color: $text-primary;
|
color: $text-primary;
|
||||||
font-weight: $font-weight-semibold;
|
font-weight: $font-weight-semibold;
|
||||||
|
|||||||
@ -1,106 +0,0 @@
|
|||||||
@use "../../assets/scss/variables" as *;
|
|
||||||
@use "../../assets/scss/mixins" as *;
|
|
||||||
@use "../../assets/scss/global" as *;
|
|
||||||
|
|
||||||
.recipe-form {
|
|
||||||
@include card;
|
|
||||||
max-width: 680px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-lg;
|
|
||||||
padding: $spacing-lg;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: $font-size-xl;
|
|
||||||
color: $text-primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-xs;
|
|
||||||
|
|
||||||
&.field-inline {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: $spacing-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
color: $text-secondary;
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-xs;
|
|
||||||
|
|
||||||
input[type="text"],
|
|
||||||
input[type="email"],
|
|
||||||
input:not([type]),
|
|
||||||
select {
|
|
||||||
padding: $spacing-sm $spacing-md;
|
|
||||||
border: 2px solid $border-color;
|
|
||||||
border-radius: $radius-md;
|
|
||||||
font-size: $font-size-base;
|
|
||||||
background: $bg-primary;
|
|
||||||
color: $text-primary;
|
|
||||||
transition:
|
|
||||||
border-color $transition-base,
|
|
||||||
box-shadow $transition-base;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus,
|
|
||||||
select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: $primary;
|
|
||||||
box-shadow: 0 0 0 4px rgba($primary, 0.06);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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%;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,66 +1,69 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { apiService } from "../../services/api";
|
import { apiService } from '../../services/api';
|
||||||
import type { AbraServer } from "../../types";
|
import type { AbraServer } from '../../types';
|
||||||
import "./RecipeForm.scss";
|
|
||||||
|
|
||||||
|
|
||||||
function RecipeForm({ recipe, onClose }) {
|
function RecipeForm({ recipe, onClose }) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [servers, setServers] = useState<AbraServer[]>([]);
|
const [servers, setServers] = useState<AbraServer[]>([]);
|
||||||
const [selectedServer, setSelectedServer] = useState(""); // ❌ only one value
|
const [error, setError] = useState('');
|
||||||
const [chaos, setChaos] = useState(false);
|
|
||||||
const [secrets, setSecrets] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [serversData] = await Promise.all([apiService.getServers()]);
|
if (servers.length === 0) {
|
||||||
|
const [serversData] = await Promise.all([
|
||||||
setServers(serversData);
|
apiService.getServers(),
|
||||||
} catch (err) {
|
]);
|
||||||
setError(err instanceof Error ? err.message : "Failed to load servers");
|
|
||||||
} finally {
|
setServers(serversData);
|
||||||
setLoading(false);
|
}
|
||||||
}
|
|
||||||
};
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load servers');
|
||||||
fetchData();
|
} finally {
|
||||||
});
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
server: "",
|
||||||
domain: "",
|
domain: "",
|
||||||
chaos: false,
|
chaos: false,
|
||||||
secrets: true,
|
secrets: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
setFormData({
|
const {name, type, value, checked} = e.target;
|
||||||
...formData,
|
setFormData((prev) => ({
|
||||||
[e.target.name]: e.target.value,
|
...prev,
|
||||||
});
|
[name]: type === "checkbox" ? checked : value,
|
||||||
|
}));
|
||||||
|
console.log(formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log("Submitting:", formData);
|
console.log("Submitting:", formData);
|
||||||
|
apiService.newApp(recipe.name, formData);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="recipe-form" onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<h2>{recipe.name}</h2>
|
<h2>{recipe.name}</h2>
|
||||||
<p className="form-subtitle">Configure and deploy this recipe.</p>
|
{ loading ? (<p> Loading servers...</p>
|
||||||
{error && <div className="form-error">{error}</div>}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="loading">Loading servers...</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="field">
|
<div>
|
||||||
<label>
|
<label>
|
||||||
Choose a server to deploy to:
|
Choose a server to deploy to:
|
||||||
<select
|
<select
|
||||||
className="select-input"
|
name="server"
|
||||||
value={selectedServer}
|
value={formData.server || ""}
|
||||||
onChange={(e) => setSelectedServer(e.target.value)}
|
onChange={handleChange}
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{servers.map((server) => (
|
{servers.map((server) => (
|
||||||
@ -69,58 +72,45 @@ function RecipeForm({ recipe, onClose }) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div>
|
||||||
<div className="field">
|
|
||||||
<label>
|
<label>
|
||||||
Domain:
|
Domain:
|
||||||
<input
|
<input
|
||||||
name="domain"
|
name="domain"
|
||||||
placeholder="example.com"
|
|
||||||
value={formData.domain}
|
value={formData.domain}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div className="field field-inline">
|
<label>
|
||||||
<label className="checkbox-label">
|
Chaos Mode Enabled:
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="chaos"
|
name="chaos"
|
||||||
checked={formData.chaos}
|
onChange={handleChange}
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, chaos: e.target.checked })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
Chaos Mode
|
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="checkbox-label">
|
<div>
|
||||||
|
<label>
|
||||||
|
Autogenerate Secrets:
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="secrets"
|
name="secrets"
|
||||||
checked={formData.secrets}
|
onChange={handleChange}
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, secrets: e.target.checked })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
Autogenerate Secrets
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-actions">
|
<button type="submit">Submit</button>
|
||||||
<button className="action-btn primary" type="submit">
|
<button type="button" onClick={onClose}>Cancel</button>
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
<button className="action-btn" type="button" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RecipeForm;
|
export default RecipeForm;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
@use "../../assets/scss/variables" as *;
|
@use '../../assets/scss/variables' as *;
|
||||||
@use "../../assets/scss/mixins" as *;
|
@use '../../assets/scss/mixins' as *;
|
||||||
@use "../../assets/scss/global" as *;
|
@use '../../assets/scss/global' as *;
|
||||||
|
|
||||||
// Extend global page wrapper
|
// Extend global page wrapper
|
||||||
.recipes-page {
|
.recipes-page {
|
||||||
@ -11,11 +11,10 @@
|
|||||||
@extend .page-content;
|
@extend .page-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recipes grid
|
// Servers grid
|
||||||
.recipes-grid {
|
.recipes-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-content: stretch;
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
||||||
gap: $spacing-xl;
|
gap: $spacing-xl;
|
||||||
margin-bottom: $spacing-xl;
|
margin-bottom: $spacing-xl;
|
||||||
|
|
||||||
@ -24,18 +23,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recipe card
|
// Server card
|
||||||
.recipe-card {
|
.recipe-card {
|
||||||
@include card;
|
@include card;
|
||||||
@include card-dimensions(320px, 180px);
|
display: grid;
|
||||||
row-gap: 1em;
|
|
||||||
grid-template-rows: 1fr 2fr auto 0.9fr;
|
grid-template-rows: 1fr 2fr auto 0.9fr;
|
||||||
transition:
|
transition: transform $transition-base, box-shadow $transition-base;
|
||||||
transform $transition-base,
|
|
||||||
box-shadow $transition-base;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-width: 30em;
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: $shadow-xl;
|
||||||
|
}
|
||||||
|
|
||||||
.recipe-header {
|
.recipe-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -79,6 +79,7 @@
|
|||||||
color: rgb(255, 255, 255);
|
color: rgb(255, 255, 255);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background-color: rgb(111, 128, 255);
|
background-color: rgb(111, 128, 255);
|
||||||
|
|
||||||
}
|
}
|
||||||
.tag-feature {
|
.tag-feature {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -87,6 +88,7 @@
|
|||||||
color: rgb(255, 255, 255);
|
color: rgb(255, 255, 255);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background-color: rgb(22, 180, 22);
|
background-color: rgb(22, 180, 22);
|
||||||
|
|
||||||
}
|
}
|
||||||
.recipe-stats {
|
.recipe-stats {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
@ -99,6 +101,7 @@
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,12 +110,21 @@
|
|||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
.action-btn {
|
.action-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@include action-btn(
|
padding: $spacing-sm $spacing-md;
|
||||||
2px,
|
border: 2px solid $border-color;
|
||||||
$radius-md,
|
background: none;
|
||||||
$spacing-sm $spacing-md,
|
color: $text-primary;
|
||||||
$font-size-sm
|
border-radius: $radius-md;
|
||||||
);
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba($primary, 0.1);
|
||||||
|
border-color: $primary;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
&.primary {
|
&.primary {
|
||||||
background-color: $primary;
|
background-color: $primary;
|
||||||
@ -149,12 +161,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
|
|||||||
@ -1,59 +1,61 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Header } from "../../components/Header/Header";
|
import { Header } from '../../components/Header/Header';
|
||||||
import { apiService } from "../../services/api";
|
import { apiService } from '../../services/api';
|
||||||
import type { AbraApp, AbraRecipe, AppWithServer } from "../../types";
|
import type { AbraApp, AppWithServer, AbraRecipe } from '../../types';
|
||||||
import RecipeForm from "./RecipeForm.tsx";
|
import RecipeForm from './RecipeForm.tsx'
|
||||||
import "./Recipes.scss";
|
import './Recipes.scss';
|
||||||
|
|
||||||
export const Recipes: React.FC = () => {
|
export const Recipes: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [recipesData, setRecipesData] = useState<AbraRecipe[] | null>(null);
|
const [recipesData, setRecipesData] = useState<AbraRecipe[] | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState('');
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [filterServer, setFilterServer] = useState<string>("all");
|
const [filterServer, setFilterServer] = useState<string>('all');
|
||||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||||
const [selectedRecipe, setSelectedRecipe] = useState(null);
|
const [selectedRecipe, setSelectedRecipe] = useState(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
const isMockMode = import.meta.env.VITE_MOCK_AUTH === "true";
|
|
||||||
|
|
||||||
|
const isMockMode = false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
if (isMockMode) {
|
if (isMockMode) {
|
||||||
const { mockApiService } = await import("../../services/mockApi");
|
const { mockApiService } = await import('../../services/mockApi');
|
||||||
const data = await mockApiService.getRecipes();
|
const data = await mockApiService.getRecipes();
|
||||||
console.log(data);
|
console.log(data)
|
||||||
setRecipesData(data);
|
setRecipesData(data);
|
||||||
} else {
|
} else {
|
||||||
const data = await apiService.getRecipes();
|
const data = await apiService.getRecipes();
|
||||||
setRecipesData(data);
|
setRecipesData(data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load apps");
|
setError(err instanceof Error ? err.message : 'Failed to load apps');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [isMockMode]);
|
}, [isMockMode]);
|
||||||
|
|
||||||
// Flatten and enrich apps data
|
// Flatten and enrich apps data
|
||||||
const allRecipes: AbraRecipe[] = useMemo(() => {
|
const allRecipes: AbraRecipe[] = useMemo(() => {
|
||||||
if (!recipesData) return [];
|
if (!recipesData) return [];
|
||||||
|
|
||||||
return recipesData;
|
return recipesData;
|
||||||
}, [recipesData]);
|
}, [recipesData]);
|
||||||
|
|
||||||
// Filter recipes
|
|
||||||
|
// Filter apps
|
||||||
const filteredRecipes = useMemo(() => {
|
const filteredRecipes = useMemo(() => {
|
||||||
return allRecipes.filter((recipe) => {
|
return allRecipes.filter(recipe => {
|
||||||
const matchesSearch = recipe.name
|
const matchesSearch =
|
||||||
.toLowerCase()
|
recipe.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
.includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
return matchesSearch;
|
return matchesSearch;
|
||||||
});
|
});
|
||||||
@ -70,7 +72,7 @@ export const Recipes: React.FC = () => {
|
|||||||
<div className="apps-page">
|
<div className="apps-page">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="recipes-content">
|
<main className="recipes-content">
|
||||||
<div className="loading">Loading applications...</div>
|
<div className="loading">Loading recipes...</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -92,38 +94,55 @@ export const Recipes: React.FC = () => {
|
|||||||
<Header />
|
<Header />
|
||||||
<main className="recipes-content">
|
<main className="recipes-content">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Recipes</h1>
|
<h1>Applications</h1>
|
||||||
<p className="subtitle">{stats.total} recipes</p>
|
<p className="subtitle">{stats.total} recipes</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Overview */}
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-info">
|
||||||
|
<p className="stat-number">{stats.total}</p>
|
||||||
|
<p className="stat-label">Total Recipes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="filters">
|
<div className="filters">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search recipes by name or description..."
|
placeholder="Search apps by name, recipe, or domain..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="search-input"
|
className="search-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="stable">Stable</option>
|
||||||
|
<option value="chaos">Chaos Mode</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Server Cards */}
|
{/* Server Cards */}
|
||||||
<div className="recipes-grid">
|
<div className="recipes-grid">
|
||||||
{filteredRecipes.length === 0 ? (
|
{filteredRecipes.length === 0 ? (
|
||||||
<div className="no-results">
|
<div className="no-results">No recipes found matching your search</div>
|
||||||
No recipes found matching your search
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
filteredRecipes.map((recipe) => (
|
filteredRecipes.map((recipe) => (
|
||||||
<div key={recipe.name} className="recipe-card">
|
<div
|
||||||
|
key={recipe.name}
|
||||||
|
className="recipe-card"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
<div className="recipe-header">
|
<div className="recipe-header">
|
||||||
<div className="recipe-title">
|
<div className="recipe-title">
|
||||||
<h3>{recipe.name}</h3>
|
<h3>{recipe.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{recipe.icon.length > 0 ? (
|
{recipe.icon.length > 0 ? <img src={`${recipe.icon}`} /> : null }
|
||||||
<img src={`${recipe.icon}`} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -134,44 +153,32 @@ export const Recipes: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="recipe-actions">
|
<div className="recipe-actions">
|
||||||
<button
|
<button
|
||||||
className="action-btn"
|
className="action-btn"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
console.log("clicked");
|
console.log('clicked');
|
||||||
setSelectedRecipe(recipe);
|
setSelectedRecipe(recipe);
|
||||||
console.log("selectedRecipe:", selectedRecipe);
|
console.log('selectedRecipe:', selectedRecipe);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add Recipe
|
Add Recipe
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-tags">
|
<div className="card-tags">
|
||||||
{recipe.features.backups.toLowerCase().includes("yes") ? (
|
{recipe.features.backups.toLowerCase().includes("yes") ? <span className="tag-feature"> Backups </span> : null}
|
||||||
<span className="tag-feature"> Backups </span>
|
{recipe.features.healthcheck.toLowerCase().includes("yes") ? <span className="tag-feature"> Healthcheck </span> : null}
|
||||||
) : null}
|
{recipe.category.length > 0 ? <span className="tag-category"> {recipe.category} </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>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedRecipe && (
|
{selectedRecipe && (
|
||||||
<div
|
<div className="modal-overlay" onClick={() => setSelectedRecipe(null)}>
|
||||||
className="modal-overlay"
|
|
||||||
onClick={() => setSelectedRecipe(null)}
|
|
||||||
>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
{}
|
{}
|
||||||
<RecipeForm
|
<RecipeForm recipe={selectedRecipe} onClose={() => setSelectedRecipe(null)} />
|
||||||
recipe={selectedRecipe}
|
|
||||||
onClose={() => setSelectedRecipe(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -182,4 +189,4 @@ export const Recipes: React.FC = () => {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -1,7 +1,6 @@
|
|||||||
@use "../../assets/scss/variables" as *;
|
@use '../../assets/scss/variables' as *;
|
||||||
@use "../../assets/scss/mixins" as *;
|
@use '../../assets/scss/mixins' as *;
|
||||||
@use "../../assets/scss/global" as *;
|
@use '../../assets/scss/global' as *;
|
||||||
@use "sass:color";
|
|
||||||
|
|
||||||
.server-detail-page {
|
.server-detail-page {
|
||||||
@extend .page-wrapper;
|
@extend .page-wrapper;
|
||||||
@ -48,7 +47,25 @@
|
|||||||
|
|
||||||
// Action buttons (shared with App view, could be moved to global)
|
// Action buttons (shared with App view, could be moved to global)
|
||||||
.action-btn {
|
.action-btn {
|
||||||
@include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm);
|
padding: $spacing-sm $spacing-lg;
|
||||||
|
border: 2px solid $border-color;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-base;
|
||||||
|
background: white;
|
||||||
|
color: $text-primary;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
&.primary {
|
&.primary {
|
||||||
background: $primary;
|
background: $primary;
|
||||||
@ -67,7 +84,7 @@
|
|||||||
border-color: $border-color;
|
border-color: $border-color;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: color.adjust($bg-secondary, $lightness: -10%);
|
background: darken($bg-secondary, 5%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,7 +94,7 @@
|
|||||||
border-color: $error;
|
border-color: $error;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: color.adjust($error, $lightness: -10%);
|
background: darken($error, 10%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -107,7 +124,7 @@
|
|||||||
gap: $spacing-xs;
|
gap: $spacing-xs;
|
||||||
padding: $spacing-xs $spacing-md;
|
padding: $spacing-xs $spacing-md;
|
||||||
background: rgba($warning, 0.1);
|
background: rgba($warning, 0.1);
|
||||||
background: color.adjust($error, $lightness: -20%);
|
color: darken($warning, 20%);
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
font-size: $font-size-sm;
|
font-size: $font-size-sm;
|
||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-medium;
|
||||||
@ -172,11 +189,11 @@
|
|||||||
color: $text-primary;
|
color: $text-primary;
|
||||||
|
|
||||||
&.warning {
|
&.warning {
|
||||||
background: color.adjust($warning, $lightness: -10%);
|
color: darken($warning, 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.chaos {
|
&.chaos {
|
||||||
background: color.adjust($info, $lightness: -10%);
|
color: darken($info, 10%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,7 +212,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.apps-list {
|
.apps-list {
|
||||||
@include card-list-vertical;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-item {
|
.app-item {
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Header } from "../../components/Header/Header";
|
import { Header } from '../../components/Header/Header';
|
||||||
import { Terminal } from "../../components/Terminal/Terminal";
|
import { Terminal } from '../../components/Terminal/Terminal';
|
||||||
import { apiService } from "../../services/api";
|
import { apiService } from '../../services/api';
|
||||||
import type { AbraServer, ServerAppsResponse } from "../../types";
|
import type { AbraServer, ServerAppsResponse } from '../../types';
|
||||||
import type { LogEntry } from "../../services/mockApi";
|
import type { LogEntry } from '../../services/mockApi';
|
||||||
import "./Server.scss";
|
import './Server.scss';
|
||||||
|
|
||||||
interface ServerWithApps extends AbraServer {
|
interface ServerWithApps extends AbraServer {
|
||||||
apps: any[];
|
apps: any[];
|
||||||
@ -19,29 +19,29 @@ interface ServerWithApps extends AbraServer {
|
|||||||
export const Server: React.FC = () => {
|
export const Server: React.FC = () => {
|
||||||
const { serverName } = useParams<{ serverName: string }>();
|
const { serverName } = useParams<{ serverName: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [server, setServer] = useState<ServerWithApps | null>(null);
|
const [server, setServer] = useState<ServerWithApps | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState('');
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
// Terminal state
|
// Terminal state
|
||||||
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
|
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
|
||||||
const [terminalActive, setTerminalActive] = useState(false);
|
const [terminalActive, setTerminalActive] = useState(false);
|
||||||
|
|
||||||
const isMockMode = import.meta.env.VITE_MOCK_AUTH === "true";
|
const isMockMode = false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchServer = async () => {
|
const fetchServer = async () => {
|
||||||
try {
|
try {
|
||||||
if (isMockMode) {
|
if (isMockMode) {
|
||||||
const { mockApiService } = await import("../../services/mockApi");
|
const { mockApiService } = await import('../../services/mockApi');
|
||||||
const appsData = await mockApiService.getAppsGrouped();
|
const appsData = await mockApiService.getAppsGrouped();
|
||||||
const serversData = await mockApiService.getServers();
|
const serversData = await mockApiService.getServers();
|
||||||
|
|
||||||
const foundServer = serversData.find((s) => s.name === serverName);
|
const foundServer = serversData.find(s => s.name === serverName);
|
||||||
const serverApps = appsData[serverName || ""];
|
const serverApps = appsData[serverName || ''];
|
||||||
|
|
||||||
if (foundServer && serverApps) {
|
if (foundServer && serverApps) {
|
||||||
setServer({
|
setServer({
|
||||||
...foundServer,
|
...foundServer,
|
||||||
@ -53,15 +53,15 @@ export const Server: React.FC = () => {
|
|||||||
latestCount: serverApps.latestCount,
|
latestCount: serverApps.latestCount,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setError("Server not found");
|
setError('Server not found');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const appsData = await apiService.getAppsGrouped();
|
const appsData = await apiService.getAppsGrouped();
|
||||||
const serversData = await apiService.getServers();
|
const serversData = await apiService.getServers();
|
||||||
|
|
||||||
const foundServer = serversData.find((s) => s.name === serverName);
|
const foundServer = serversData.find(s => s.name === serverName);
|
||||||
const serverApps = appsData[serverName || ""];
|
const serverApps = appsData[serverName || ''];
|
||||||
|
|
||||||
if (foundServer && serverApps) {
|
if (foundServer && serverApps) {
|
||||||
setServer({
|
setServer({
|
||||||
...foundServer,
|
...foundServer,
|
||||||
@ -73,11 +73,11 @@ export const Server: React.FC = () => {
|
|||||||
latestCount: serverApps.latestCount,
|
latestCount: serverApps.latestCount,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setError("Server not found");
|
setError('Server not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load server");
|
setError(err instanceof Error ? err.message : 'Failed to load server');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -88,36 +88,28 @@ export const Server: React.FC = () => {
|
|||||||
|
|
||||||
const handleAction = async (action: string) => {
|
const handleAction = async (action: string) => {
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
|
|
||||||
setActionLoading(action);
|
setActionLoading(action);
|
||||||
setTerminalActive(true);
|
setTerminalActive(true);
|
||||||
setTerminalLogs([]);
|
setTerminalLogs([]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isMockMode) {
|
if (isMockMode) {
|
||||||
const { mockApiService } = await import("../../services/mockApi");
|
const { mockApiService } = await import('../../services/mockApi');
|
||||||
|
|
||||||
const onLog = (log: LogEntry) => {
|
const onLog = (log: LogEntry) => {
|
||||||
setTerminalLogs((prev) => [...prev, log]);
|
setTerminalLogs(prev => [...prev, log]);
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "refresh":
|
case 'refresh':
|
||||||
await mockApiService.refreshServer(server.name, onLog);
|
await mockApiService.refreshServer(server.name, onLog);
|
||||||
break;
|
break;
|
||||||
case "deploy-all":
|
case 'deploy-all':
|
||||||
await mockApiService.deployAllApps(
|
await mockApiService.deployAllApps(server.name, server.appCount, onLog);
|
||||||
server.name,
|
|
||||||
server.appCount,
|
|
||||||
onLog,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "upgrade-all":
|
case 'upgrade-all':
|
||||||
await mockApiService.upgradeAllApps(
|
await mockApiService.upgradeAllApps(server.name, server.upgradeCount, onLog);
|
||||||
server.name,
|
|
||||||
server.upgradeCount,
|
|
||||||
onLog,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -125,15 +117,12 @@ export const Server: React.FC = () => {
|
|||||||
console.log(`Action: ${action} on server ${server.name}`);
|
console.log(`Action: ${action} on server ${server.name}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Action failed:", err);
|
console.error('Action failed:', err);
|
||||||
setTerminalLogs((prev) => [
|
setTerminalLogs(prev => [...prev, {
|
||||||
...prev,
|
type: 'error',
|
||||||
{
|
text: `❌ Error: ${err instanceof Error ? err.message : 'Action failed'}`,
|
||||||
type: "error",
|
timestamp: new Date()
|
||||||
text: `❌ Error: ${err instanceof Error ? err.message : "Action failed"}`,
|
}]);
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@ -155,8 +144,8 @@ export const Server: React.FC = () => {
|
|||||||
<div className="server-detail-page">
|
<div className="server-detail-page">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="server-detail-content">
|
<main className="server-detail-content">
|
||||||
<div className="error">{error || "Server not found"}</div>
|
<div className="error">{error || 'Server not found'}</div>
|
||||||
<button onClick={() => navigate("/servers")} className="back-button">
|
<button onClick={() => navigate('/servers')} className="back-button">
|
||||||
Back to Servers
|
Back to Servers
|
||||||
</button>
|
</button>
|
||||||
</main>
|
</main>
|
||||||
@ -164,20 +153,15 @@ export const Server: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const chaosApps = server.apps.filter((app) => app.chaos === "true");
|
const chaosApps = server.apps.filter(app => app.chaos === 'true');
|
||||||
const runningApps = server.apps.filter(
|
const runningApps = server.apps.filter(app => app.status === 'deployed' || app.status === 'running');
|
||||||
(app) => app.status === "deployed" || app.status === "running",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="server-detail-page">
|
<div className="server-detail-page">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="server-detail-content">
|
<main className="server-detail-content">
|
||||||
<div className="breadcrumb">
|
<div className="breadcrumb">
|
||||||
<button
|
<button onClick={() => navigate('/servers')} className="breadcrumb-link">
|
||||||
onClick={() => navigate("/servers")}
|
|
||||||
className="breadcrumb-link"
|
|
||||||
>
|
|
||||||
Servers
|
Servers
|
||||||
</button>
|
</button>
|
||||||
<span className="breadcrumb-separator">/</span>
|
<span className="breadcrumb-separator">/</span>
|
||||||
@ -191,34 +175,33 @@ export const Server: React.FC = () => {
|
|||||||
<span className="host-badge">{server.host}</span>
|
<span className="host-badge">{server.host}</span>
|
||||||
{server.upgradeCount > 0 && (
|
{server.upgradeCount > 0 && (
|
||||||
<span className="upgrade-badge">
|
<span className="upgrade-badge">
|
||||||
{server.upgradeCount} upgrade
|
⬆️ {server.upgradeCount} upgrade{server.upgradeCount !== 1 ? 's' : ''}
|
||||||
{server.upgradeCount !== 1 ? "s" : ""}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="server-actions">
|
<div className="server-actions">
|
||||||
<button
|
<button
|
||||||
className="action-btn secondary"
|
className="action-btn secondary"
|
||||||
onClick={() => handleAction("refresh")}
|
onClick={() => handleAction('refresh')}
|
||||||
disabled={!!actionLoading}
|
disabled={!!actionLoading}
|
||||||
>
|
>
|
||||||
{actionLoading === "refresh" ? "Refreshing..." : "Refresh"}
|
{actionLoading === 'refresh' ? 'Refreshing...' : '🔄 Refresh'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="action-btn"
|
className="action-btn primary"
|
||||||
onClick={() => handleAction("deploy-all")}
|
onClick={() => handleAction('deploy-all')}
|
||||||
disabled={!!actionLoading}
|
disabled={!!actionLoading}
|
||||||
>
|
>
|
||||||
{actionLoading === "deploy-all" ? "Deploying..." : "Deploy All"}
|
{actionLoading === 'deploy-all' ? 'Deploying...' : '🚀 Deploy All'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terminal Component */}
|
{/* Terminal Component */}
|
||||||
<Terminal
|
<Terminal
|
||||||
logs={terminalLogs}
|
logs={terminalLogs}
|
||||||
isActive={terminalActive}
|
isActive={terminalActive}
|
||||||
onClose={() => setTerminalActive(false)}
|
onClose={() => setTerminalActive(false)}
|
||||||
/>
|
/>
|
||||||
@ -228,7 +211,7 @@ export const Server: React.FC = () => {
|
|||||||
<div className="main-column">
|
<div className="main-column">
|
||||||
<section className="info-card">
|
<section className="info-card">
|
||||||
<h2>Server Details</h2>
|
<h2>Server Details</h2>
|
||||||
|
|
||||||
<div className="info-grid">
|
<div className="info-grid">
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<label>Server Name</label>
|
<label>Server Name</label>
|
||||||
@ -252,24 +235,14 @@ export const Server: React.FC = () => {
|
|||||||
|
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<label>Upgrades Available</label>
|
<label>Upgrades Available</label>
|
||||||
<span
|
<span className={server.upgradeCount > 0 ? 'stat-value warning' : 'stat-value'}>
|
||||||
className={
|
|
||||||
server.upgradeCount > 0
|
|
||||||
? "stat-value warning"
|
|
||||||
: "stat-value"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{server.upgradeCount}
|
{server.upgradeCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<label>Chaos Mode Apps</label>
|
<label>Chaos Mode Apps</label>
|
||||||
<span
|
<span className={chaosApps.length > 0 ? 'stat-value chaos' : 'stat-value'}>
|
||||||
className={
|
|
||||||
chaosApps.length > 0 ? "stat-value chaos" : "stat-value"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{chaosApps.length}
|
{chaosApps.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -288,20 +261,16 @@ export const Server: React.FC = () => {
|
|||||||
|
|
||||||
<section className="info-card">
|
<section className="info-card">
|
||||||
<h2>Applications on this Server</h2>
|
<h2>Applications on this Server</h2>
|
||||||
|
|
||||||
{server.apps.length === 0 ? (
|
{server.apps.length === 0 ? (
|
||||||
<div className="no-apps">
|
<div className="no-apps">No applications deployed on this server</div>
|
||||||
No applications deployed on this server
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="apps-list">
|
<div className="apps-list">
|
||||||
{server.apps.map((app, idx) => (
|
{server.apps.map((app, idx) => (
|
||||||
<div
|
<div
|
||||||
key={`${app.appName}-${idx}`}
|
key={`${app.appName}-${idx}`}
|
||||||
className="app-item"
|
className="app-item"
|
||||||
onClick={() =>
|
onClick={() => navigate(`/apps/${server.name}/${app.appName}`)}
|
||||||
navigate(`/apps/${server.name}/${app.appName}`)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="app-item-header">
|
<div className="app-item-header">
|
||||||
<span className="app-item-name">{app.appName}</span>
|
<span className="app-item-name">{app.appName}</span>
|
||||||
@ -310,10 +279,10 @@ export const Server: React.FC = () => {
|
|||||||
<span className={`status-badge status-${app.status}`}>
|
<span className={`status-badge status-${app.status}`}>
|
||||||
{app.status}
|
{app.status}
|
||||||
</span>
|
</span>
|
||||||
{app.chaos === "true" && (
|
{app.chaos === 'true' && (
|
||||||
<span className="chaos-badge">☠️</span>
|
<span className="chaos-badge">🔬</span>
|
||||||
)}
|
)}
|
||||||
{app.upgrade !== "latest" && (
|
{app.upgrade !== 'latest' && (
|
||||||
<span className="upgrade-badge">⬆️</span>
|
<span className="upgrade-badge">⬆️</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -321,7 +290,7 @@ export const Server: React.FC = () => {
|
|||||||
<div className="app-item-details">
|
<div className="app-item-details">
|
||||||
<span className="app-item-version">v{app.version}</span>
|
<span className="app-item-version">v{app.version}</span>
|
||||||
{app.domain && (
|
{app.domain && (
|
||||||
<a
|
<a
|
||||||
href={`https://${app.domain}`}
|
href={`https://${app.domain}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@ -343,30 +312,30 @@ export const Server: React.FC = () => {
|
|||||||
<div className="sidebar-column">
|
<div className="sidebar-column">
|
||||||
<section className="info-card">
|
<section className="info-card">
|
||||||
<h2>Quick Actions</h2>
|
<h2>Quick Actions</h2>
|
||||||
|
|
||||||
<div className="action-list">
|
<div className="action-list">
|
||||||
<button
|
<button
|
||||||
className="action-list-item"
|
className="action-list-item"
|
||||||
onClick={() => handleAction("refresh")}
|
onClick={() => handleAction('refresh')}
|
||||||
disabled={!!actionLoading}
|
disabled={!!actionLoading}
|
||||||
>
|
>
|
||||||
<span className="action-text">Refresh Server Info</span>
|
<span className="action-text">Refresh Server Info</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="action-list-item"
|
className="action-list-item"
|
||||||
onClick={() => handleAction("deploy-all")}
|
onClick={() => handleAction('deploy-all')}
|
||||||
disabled={!!actionLoading}
|
disabled={!!actionLoading}
|
||||||
>
|
>
|
||||||
<span className="action-text">Deploy All Apps</span>
|
<span className="action-text">Deploy All Apps</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="action-list-item"
|
className="action-list-item"
|
||||||
onClick={() => handleAction("upgrade-all")}
|
onClick={() => handleAction('upgrade-all')}
|
||||||
disabled={!!actionLoading || server.upgradeCount === 0}
|
disabled={!!actionLoading || server.upgradeCount === 0}
|
||||||
>
|
>
|
||||||
<span className="action-text">Upgrade All Apps</span>
|
<span className="action-text">⬆Upgrade All Apps</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
@use "../../assets/scss/variables" as *;
|
@use '../../assets/scss/variables' as *;
|
||||||
@use "../../assets/scss/mixins" as *;
|
@use '../../assets/scss/mixins' as *;
|
||||||
@use "../../assets/scss/global" as *;
|
@use '../../assets/scss/global' as *;
|
||||||
|
|
||||||
// Extend global page wrapper
|
// Extend global page wrapper
|
||||||
.servers-page {
|
.servers-page {
|
||||||
@ -14,12 +14,10 @@
|
|||||||
// Servers grid
|
// Servers grid
|
||||||
.servers-grid {
|
.servers-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||||
gap: $spacing-xl;
|
gap: $spacing-xl;
|
||||||
margin-bottom: $spacing-xl;
|
margin-bottom: $spacing-xl;
|
||||||
|
|
||||||
align-items: stretch;
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@ -28,24 +26,44 @@
|
|||||||
// Server card
|
// Server card
|
||||||
.server-card {
|
.server-card {
|
||||||
@include card;
|
@include card;
|
||||||
@include card-hover-lift(-4px, $shadow-xl);
|
|
||||||
@include card-dimensions(320px, 180px);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
transition: transform $transition-base, box-shadow $transition-base;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: $shadow-xl;
|
||||||
|
}
|
||||||
|
|
||||||
.server-header {
|
.server-header {
|
||||||
@include 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;
|
||||||
|
|
||||||
.server-title {
|
.server-title {
|
||||||
@include card-title-stack;
|
h3 {
|
||||||
|
margin: 0 0 $spacing-xs;
|
||||||
|
font-size: $font-size-xl;
|
||||||
|
color: $text-primary;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-host {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $text-muted;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-status {
|
.server-status {
|
||||||
.status-indicator {
|
.status-indicator {
|
||||||
font-size: $font-size-xl;
|
font-size: $font-size-xl;
|
||||||
|
|
||||||
&.connected {
|
&.connected {
|
||||||
color: $success;
|
color: $success;
|
||||||
}
|
}
|
||||||
@ -62,12 +80,24 @@
|
|||||||
margin-bottom: $spacing-lg;
|
margin-bottom: $spacing-lg;
|
||||||
|
|
||||||
.stat-row {
|
.stat-row {
|
||||||
@include 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;
|
||||||
|
}
|
||||||
|
|
||||||
// Highlighted rows
|
// Highlighted rows
|
||||||
&.highlight {
|
&.highlight {
|
||||||
@include card-row-highlight-bleed;
|
|
||||||
background-color: rgba($warning, 0.05);
|
background-color: rgba($warning, 0.05);
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
margin: $spacing-sm (-$spacing-xl);
|
||||||
|
padding-left: calc($spacing-xl + $spacing-md);
|
||||||
|
padding-right: calc($spacing-xl + $spacing-md);
|
||||||
|
border-bottom: none;
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-weight: $font-weight-semibold;
|
font-weight: $font-weight-semibold;
|
||||||
@ -79,8 +109,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.chaos-row {
|
&.chaos-row {
|
||||||
@include card-row-highlight-bleed(false);
|
|
||||||
background-color: rgba($info, 0.05);
|
background-color: rgba($info, 0.05);
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
margin: $spacing-sm (-$spacing-xl);
|
||||||
|
padding-left: calc($spacing-xl + $spacing-md);
|
||||||
|
padding-right: calc($spacing-xl + $spacing-md);
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-weight: $font-weight-semibold;
|
font-weight: $font-weight-semibold;
|
||||||
@ -119,17 +152,26 @@
|
|||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@include action-btn(
|
padding: $spacing-sm $spacing-md;
|
||||||
2px,
|
border: 2px solid $border-color;
|
||||||
$radius-md,
|
background: none;
|
||||||
$spacing-sm $spacing-md,
|
color: $text-primary;
|
||||||
$font-size-sm
|
border-radius: $radius-md;
|
||||||
);
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba($primary, 0.1);
|
||||||
|
border-color: $primary;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
&.primary {
|
&.primary {
|
||||||
// background-color: $primary;
|
background-color: $primary;
|
||||||
// color: white;
|
color: white;
|
||||||
// border-color: $primary;
|
border-color: $primary;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $primary-light;
|
background-color: $primary-light;
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Header } from "../../components/Header/Header";
|
import { Header } from '../../components/Header/Header';
|
||||||
import { apiService } from "../../services/api";
|
import { apiService } from '../../services/api';
|
||||||
import type { AbraServer, ServerAppsResponse } from "../../types";
|
import type { AbraServer, ServerAppsResponse } from '../../types';
|
||||||
import "./Servers.scss";
|
import './Servers.scss';
|
||||||
|
|
||||||
interface ServerWithStats extends AbraServer {
|
interface ServerWithStats extends AbraServer {
|
||||||
appCount: number;
|
appCount: number;
|
||||||
@ -17,52 +17,63 @@ export const Servers: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [servers, setServers] = useState<ServerWithStats[]>([]);
|
const [servers, setServers] = useState<ServerWithStats[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState('');
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<"name" | "apps" | "upgrades">("name");
|
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";
|
const isMockMode = false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
let serversData, appsData;
|
|
||||||
|
|
||||||
if (isMockMode) {
|
if (isMockMode) {
|
||||||
const { mockApiService } = await import("../../services/mockApi");
|
const { mockApiService } = await import('../../services/mockApi');
|
||||||
[serversData, appsData] = await Promise.all([
|
const [serversData, appsData] = await Promise.all([
|
||||||
mockApiService.getServers(),
|
mockApiService.getServers(),
|
||||||
mockApiService.getAppsGrouped(),
|
mockApiService.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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setServers(enrichedServers);
|
||||||
} else {
|
} else {
|
||||||
[serversData, appsData] = await Promise.all([
|
const [serversData, appsData] = await Promise.all([
|
||||||
apiService.getServers(),
|
apiService.getServers(),
|
||||||
apiService.getAppsGrouped(),
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setServers(enrichedServers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setServers(enrichedServers);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error loading servers:", err);
|
setError(err instanceof Error ? err.message : 'Failed to load servers');
|
||||||
setError(err instanceof Error ? err.message : "Failed to load servers");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -81,29 +92,28 @@ export const Servers: React.FC = () => {
|
|||||||
return { totalServers, totalApps, totalUpgrades, totalChaos };
|
return { totalServers, totalApps, totalUpgrades, totalChaos };
|
||||||
}, [servers]);
|
}, [servers]);
|
||||||
|
|
||||||
// Filter and sort servers (additive filters allowed)
|
// Filter and sort servers
|
||||||
const filteredServers = useMemo(() => {
|
const filteredServers = useMemo(() => {
|
||||||
const filtered = servers.filter((server) => {
|
const filtered = servers.filter(server =>
|
||||||
const matchesSearch =
|
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
server.host.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;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Sort
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case "apps":
|
case 'apps':
|
||||||
return b.appCount - a.appCount;
|
return b.appCount - a.appCount;
|
||||||
case "name":
|
case 'upgrades':
|
||||||
|
return b.upgradeCount - a.upgradeCount;
|
||||||
|
case 'name':
|
||||||
default:
|
default:
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [servers, searchTerm, sortBy, showUpgradesOnly]);
|
}, [servers, searchTerm, sortBy]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -133,42 +143,39 @@ export const Servers: React.FC = () => {
|
|||||||
<main className="servers-content">
|
<main className="servers-content">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Servers</h1>
|
<h1>Servers</h1>
|
||||||
<p className="subtitle">
|
<p className="subtitle">Managing {stats.totalServers} servers with {stats.totalApps} applications</p>
|
||||||
Managing {stats.totalServers} servers with {stats.totalApps}{" "}
|
|
||||||
applications
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compact Stats Row */}
|
{/* Stats Overview */}
|
||||||
<div className="stats-row">
|
<div className="stats-grid">
|
||||||
<button
|
<div className="stat-card">
|
||||||
className="stat-chip"
|
<div className="stat-icon"></div>
|
||||||
onClick={() => navigate("/apps")}
|
<div className="stat-info">
|
||||||
title="View all apps"
|
<p className="stat-number">{stats.totalServers}</p>
|
||||||
>
|
<p className="stat-label">Total Servers</p>
|
||||||
<span className="stat-label">Apps</span>
|
</div>
|
||||||
<span className="stat-value">{stats.totalApps}</span>
|
</div>
|
||||||
</button>
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon"></div>
|
||||||
<button
|
<div className="stat-info">
|
||||||
className={`stat-chip filter-chip ${showUpgradesOnly ? "active" : ""}`}
|
<p className="stat-number">{stats.totalApps}</p>
|
||||||
onClick={() => setShowUpgradesOnly((prev) => !prev)}
|
<p className="stat-label">Total Apps</p>
|
||||||
title="Click to filter by servers with upgrades"
|
</div>
|
||||||
disabled={stats.totalUpgrades === 0}
|
</div>
|
||||||
>
|
<div className="stat-card upgrade">
|
||||||
<span className="stat-label">Upgrades</span>
|
<div className="stat-icon"></div>
|
||||||
<span className="stat-value">{stats.totalUpgrades}</span>
|
<div className="stat-info">
|
||||||
</button>
|
<p className="stat-number">{stats.totalUpgrades}</p>
|
||||||
|
<p className="stat-label">Apps Need Upgrade</p>
|
||||||
<button
|
</div>
|
||||||
className={`stat-chip filter-chip ${showChaosOnly ? "active" : ""}`}
|
</div>
|
||||||
onClick={() => setShowChaosOnly((prev) => !prev)}
|
<div className="stat-card chaos">
|
||||||
title="Click to filter servers with chaos apps"
|
<div className="stat-icon"></div>
|
||||||
disabled={stats.totalChaos === 0}
|
<div className="stat-info">
|
||||||
>
|
<p className="stat-number">{stats.totalChaos}</p>
|
||||||
<span className="stat-label">Chaos</span>
|
<p className="stat-label">Chaos Apps</p>
|
||||||
<span className="stat-value">{stats.totalChaos}</span>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@ -180,21 +187,25 @@ export const Servers: React.FC = () => {
|
|||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="search-input"
|
className="search-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)}>
|
||||||
|
<option value="name">Sort by Name</option>
|
||||||
|
<option value="apps">Sort by App Count</option>
|
||||||
|
<option value="upgrades">Sort by Upgrades</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Server Cards */}
|
{/* Server Cards */}
|
||||||
<div className="servers-grid">
|
<div className="servers-grid">
|
||||||
{filteredServers.length === 0 ? (
|
{filteredServers.length === 0 ? (
|
||||||
<div className="no-results">
|
<div className="no-results">No servers found matching your search</div>
|
||||||
No servers found matching your search
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
filteredServers.map((server) => (
|
filteredServers.map((server) => (
|
||||||
<div
|
<div
|
||||||
key={server.name}
|
key={server.name}
|
||||||
className="server-card"
|
className="server-card"
|
||||||
onClick={() => navigate(`/servers/${server.name}`)}
|
onClick={() => navigate(`/servers/${server.name}`)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
<div className="server-header">
|
<div className="server-header">
|
||||||
<div className="server-title">
|
<div className="server-title">
|
||||||
@ -202,12 +213,7 @@ export const Servers: React.FC = () => {
|
|||||||
<span className="server-host">{server.host}</span>
|
<span className="server-host">{server.host}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="server-status">
|
<div className="server-status">
|
||||||
<span
|
<span className="status-indicator connected" title="Connected">●</span>
|
||||||
className="status-indicator connected"
|
|
||||||
title="Connected"
|
|
||||||
>
|
|
||||||
●
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -238,11 +244,32 @@ export const Servers: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="server-actions">
|
||||||
|
<button
|
||||||
|
className="action-btn primary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/servers/${server.name}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View Apps
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/servers/${server.name}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{server.upgradeCount > 0 && (
|
{server.upgradeCount > 0 && (
|
||||||
<div className="server-alert">
|
<div className="server-alert">
|
||||||
|
<span className="alert-icon">⚠️</span>
|
||||||
<span className="alert-text">
|
<span className="alert-text">
|
||||||
{server.upgradeCount} app
|
{server.upgradeCount} app{server.upgradeCount > 1 ? 's' : ''} can be upgraded
|
||||||
{server.upgradeCount > 1 ? "s" : ""} can be upgraded
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -257,4 +284,4 @@ export const Servers: React.FC = () => {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -1,18 +1,17 @@
|
|||||||
import type { AbraRecipe, AbraServer, ServerAppsResponse } from "../types";
|
import type { AbraServer, AbraRecipe, ServerAppsResponse, AbraAppService, DeployEvent } from '../types';
|
||||||
|
|
||||||
// Log entry type
|
// Log entry type
|
||||||
export type LogEntry = {
|
export type LogEntry = {
|
||||||
type: "info" | "success" | "error" | "warning" | "command" | "output";
|
type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output';
|
||||||
text: string;
|
text: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
||||||
import.meta.env.VITE_API_URL || "http://localhost:3000/api";
|
|
||||||
|
|
||||||
// Helper to process log JSON from API response
|
// Helper to process log JSON from API response
|
||||||
const processLogResponse = (logData: any[]): LogEntry[] => {
|
const processLogResponse = (logData: any[]): LogEntry[] => {
|
||||||
return logData.map((log) => ({
|
return logData.map(log => ({
|
||||||
type: log.type,
|
type: log.type,
|
||||||
text: log.text,
|
text: log.text,
|
||||||
timestamp: new Date(log.timestamp || Date.now()),
|
timestamp: new Date(log.timestamp || Date.now()),
|
||||||
@ -22,10 +21,10 @@ const processLogResponse = (logData: any[]): LogEntry[] => {
|
|||||||
class ApiService {
|
class ApiService {
|
||||||
private async request<T>(
|
private async request<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
...options.headers,
|
...options.headers,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -35,153 +34,164 @@ class ApiService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response
|
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
|
||||||
.json()
|
|
||||||
.catch(() => ({ message: "An error occurred" }));
|
|
||||||
throw new Error(error.message || `HTTP ${response.status}`);
|
throw new Error(error.message || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
return response.json();
|
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();
|
||||||
|
}
|
||||||
|
// Get Logs for service
|
||||||
|
getLogs(appName: string, serviceName: string, msgHandler: (data: String) => void) {
|
||||||
|
return this.stream(`/apps/${appName}/${serviceName}/logs`, {onMessage: msgHandler})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all apps grouped by server
|
// Get all apps grouped by server
|
||||||
async getAppsGrouped(): Promise<ServerAppsResponse> {
|
async getAppsGrouped(): Promise<ServerAppsResponse> {
|
||||||
return this.request<ServerAppsResponse>("/apps");
|
return this.request<ServerAppsResponse>('/apps');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all servers
|
// Get all servers
|
||||||
async getServers(): Promise<AbraServer[]> {
|
async getServers(): Promise<AbraServer[]> {
|
||||||
return this.request<AbraServer[]>("/servers");
|
return this.request<AbraServer[]>('/servers');
|
||||||
|
}
|
||||||
|
// Get services for app
|
||||||
|
async getServices(appName: string): Promise<AbraAppService[]> {
|
||||||
|
return this.request<AbraAppService[]>(`/apps/${appName}/services`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// App actions with log streaming (websocket future)
|
// App actions with log streaming (websocket future)
|
||||||
async deployApp(
|
async deployApp(appName: string): Promise<void> {
|
||||||
appName: string,
|
return this.request<void>(`/apps/${appName}/deploy`, {
|
||||||
onLog?: (log: LogEntry) => void,
|
method: 'POST',
|
||||||
): Promise<void> {
|
});
|
||||||
const response = await this.request<{ logs: any[] }>(
|
}
|
||||||
`/apps/${appName}/deploy`,
|
deployLogs(appName: string, msgHandler: (data: DeployEvent) => void) {
|
||||||
{
|
return this.stream(`/apps/${appName}/deploy`, {parser: JSON.parse, onMessage: msgHandler})
|
||||||
method: "POST",
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
|
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) {
|
if (onLog && response.logs) {
|
||||||
const logs = processLogResponse(response.logs);
|
const logs = processLogResponse(response.logs);
|
||||||
logs.forEach((log) => onLog(log));
|
logs.forEach(log => onLog(log));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async undeployApp(
|
async upgradeApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||||
appName: string,
|
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/upgrade`, {
|
||||||
onLog?: (log: LogEntry) => void,
|
method: 'POST',
|
||||||
): Promise<void> {
|
body: JSON.stringify({ version }),
|
||||||
const response = await this.request<{ logs: any[] }>(
|
});
|
||||||
`/apps/${appName}/undeploy`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onLog && response.logs) {
|
if (onLog && response.logs) {
|
||||||
const logs = processLogResponse(response.logs);
|
const logs = processLogResponse(response.logs);
|
||||||
logs.forEach((log) => onLog(log));
|
logs.forEach(log => onLog(log));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async upgradeApp(
|
async removeApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||||
appName: string,
|
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/remove`, {
|
||||||
version: string,
|
method: 'POST',
|
||||||
onLog?: (log: LogEntry) => void,
|
});
|
||||||
): Promise<void> {
|
|
||||||
const response = await this.request<{ logs: any[] }>(
|
|
||||||
`/apps/${appName}/upgrade`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ version }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onLog && response.logs) {
|
if (onLog && response.logs) {
|
||||||
const logs = processLogResponse(response.logs);
|
const logs = processLogResponse(response.logs);
|
||||||
logs.forEach((log) => onLog(log));
|
logs.forEach(log => onLog(log));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async newApp(appName: string, formData: Object): Promise<void> {
|
||||||
async removeApp(
|
const response = await this.request<void>(`/apps/${appName}/new`, {
|
||||||
appName: string,
|
method: 'POST',
|
||||||
onLog?: (log: LogEntry) => void,
|
headers: {
|
||||||
): Promise<void> {
|
'Accept': 'application/json'
|
||||||
const response = await this.request<{ logs: any[] }>(
|
|
||||||
`/apps/${appName}/remove`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
},
|
},
|
||||||
);
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
if (onLog && response.logs) {
|
return response
|
||||||
const logs = processLogResponse(response.logs);
|
|
||||||
logs.forEach((log) => onLog(log));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server actions with log streaming
|
// Server actions with log streaming
|
||||||
async refreshServer(
|
async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||||
serverName: string,
|
const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/refresh`, {
|
||||||
onLog?: (log: LogEntry) => void,
|
method: 'POST',
|
||||||
): Promise<void> {
|
});
|
||||||
const response = await this.request<{ logs: any[] }>(
|
|
||||||
`/servers/${serverName}/refresh`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onLog && response.logs) {
|
if (onLog && response.logs) {
|
||||||
const logs = processLogResponse(response.logs);
|
const logs = processLogResponse(response.logs);
|
||||||
logs.forEach((log) => onLog(log));
|
logs.forEach(log => onLog(log));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deployAllApps(
|
async deployAllApps(serverName: string, appCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||||
serverName: string,
|
const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/deploy-all`, {
|
||||||
appCount: number,
|
method: 'POST',
|
||||||
onLog?: (log: LogEntry) => void,
|
});
|
||||||
): Promise<void> {
|
|
||||||
const response = await this.request<{ logs: any[] }>(
|
|
||||||
`/servers/${serverName}/deploy-all`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onLog && response.logs) {
|
if (onLog && response.logs) {
|
||||||
const logs = processLogResponse(response.logs);
|
const logs = processLogResponse(response.logs);
|
||||||
logs.forEach((log) => onLog(log));
|
logs.forEach(log => onLog(log));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async upgradeAllApps(
|
async upgradeAllApps(serverName: string, upgradeCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||||
serverName: string,
|
const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/upgrade-all`, {
|
||||||
upgradeCount: number,
|
method: 'POST',
|
||||||
onLog?: (log: LogEntry) => void,
|
});
|
||||||
): Promise<void> {
|
|
||||||
const response = await this.request<{ logs: any[] }>(
|
|
||||||
`/servers/${serverName}/upgrade-all`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onLog && response.logs) {
|
if (onLog && response.logs) {
|
||||||
const logs = processLogResponse(response.logs);
|
const logs = processLogResponse(response.logs);
|
||||||
logs.forEach((log) => onLog(log));
|
logs.forEach(log => onLog(log));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// recipe catalog imports
|
// recipe catalog imports
|
||||||
async getRecipes(): Promise<AbraRecipe[]> {
|
async getRecipes(): Promise<AbraRecipe[]> {
|
||||||
return this.request<AbraRecipe[]>("/abra/catalogue");
|
return this.request<AbraRecipe[]>('/catalogue');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const apiService = new ApiService();
|
export const apiService = new ApiService();
|
||||||
@ -590,4 +590,4 @@
|
|||||||
"latestCount": 3,
|
"latestCount": 3,
|
||||||
"upgradeCount": 6
|
"upgradeCount": 6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1723,4 +1723,4 @@
|
|||||||
],
|
],
|
||||||
"website": ""
|
"website": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -119,7 +119,7 @@
|
|||||||
"logs": [
|
"logs": [
|
||||||
{
|
{
|
||||||
"type": "info",
|
"type": "info",
|
||||||
"text": "Upgrading {appName} to {version}..."
|
"text": "⬆️ Upgrading {appName} to {version}..."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
@ -287,7 +287,7 @@
|
|||||||
"logs": [
|
"logs": [
|
||||||
{
|
{
|
||||||
"type": "info",
|
"type": "info",
|
||||||
"text": "Upgrading all apps on {serverName}..."
|
"text": "⬆️ Upgrading all apps on {serverName}..."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
@ -323,4 +323,4 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -27,4 +27,4 @@
|
|||||||
"host": "orgsite.org",
|
"host": "orgsite.org",
|
||||||
"name": "orgsite.org"
|
"name": "orgsite.org"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -1,19 +1,20 @@
|
|||||||
import type { AbraRecipe, AbraServer, ServerAppsResponse } from "../types";
|
import type { AbraServer, AbraRecipe, ServerAppsResponse } from '../types';
|
||||||
import appsData from "./mock-apps.json";
|
import appsData from './mock-apps.json';
|
||||||
import serversData from "./mock-servers.json";
|
import serversData from './mock-servers.json';
|
||||||
import logsData from "./mock-logs.json";
|
import logsData from './mock-logs.json';
|
||||||
import catalogue from "./mock-catalogue.json";
|
import catalogue from './mock-catalogue.json';
|
||||||
|
|
||||||
|
|
||||||
// Log entry type
|
// Log entry type
|
||||||
export type LogEntry = {
|
export type LogEntry = {
|
||||||
type: "info" | "success" | "error" | "warning" | "command" | "output";
|
type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output';
|
||||||
text: string;
|
text: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Type for the imported JSON structure
|
// Type for the imported JSON structure
|
||||||
type LogTemplate = {
|
type LogTemplate = {
|
||||||
type: "info" | "success" | "error" | "warning" | "command" | "output";
|
type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output';
|
||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -24,16 +25,13 @@ type LogsDataStructure = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Simulate API delay
|
// Simulate API delay
|
||||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
// Helper to replace template variables like {appName}, {version}
|
// Helper to replace template variables like {appName}, {version}
|
||||||
const replaceVars = (
|
const replaceVars = (text: string, vars: Record<string, string | number>): string => {
|
||||||
text: string,
|
|
||||||
vars: Record<string, string | number>,
|
|
||||||
): string => {
|
|
||||||
let result = text;
|
let result = text;
|
||||||
Object.entries(vars).forEach(([key, value]) => {
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
|
result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value));
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@ -41,25 +39,25 @@ const replaceVars = (
|
|||||||
// Helper to process log templates into LogEntry objects
|
// Helper to process log templates into LogEntry objects
|
||||||
const processLogs = (
|
const processLogs = (
|
||||||
action: string,
|
action: string,
|
||||||
vars: Record<string, string | number>,
|
vars: Record<string, string | number>
|
||||||
): LogEntry[] => {
|
): LogEntry[] => {
|
||||||
console.log("Processing logs for action:", action);
|
console.log('Processing logs for action:', action);
|
||||||
console.log("Loaded logsData:", logsData);
|
console.log('Loaded logsData:', logsData);
|
||||||
|
|
||||||
const typedLogsData = logsData as LogsDataStructure;
|
const typedLogsData = logsData as LogsDataStructure;
|
||||||
const actionData = typedLogsData[action];
|
const actionData = typedLogsData[action];
|
||||||
|
|
||||||
if (!actionData || !actionData.logs) {
|
if (!actionData || !actionData.logs) {
|
||||||
console.error(`No logs found for action: ${action}`);
|
console.error(`No logs found for action: ${action}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Found logs:", actionData.logs);
|
console.log('Found logs:', actionData.logs);
|
||||||
|
|
||||||
const templates = actionData.logs;
|
const templates = actionData.logs;
|
||||||
return templates
|
return templates
|
||||||
.filter((template) => template && template.type && template.text) // Filter out any undefined/malformed entries
|
.filter(template => template && template.type && template.text) // Filter out any undefined/malformed entries
|
||||||
.map((template) => ({
|
.map(template => ({
|
||||||
type: template.type,
|
type: template.type,
|
||||||
text: replaceVars(template.text, vars),
|
text: replaceVars(template.text, vars),
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
@ -77,87 +75,63 @@ export const mockApiService = {
|
|||||||
return serversData as AbraServer[];
|
return serversData as AbraServer[];
|
||||||
},
|
},
|
||||||
|
|
||||||
async deployApp(
|
async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||||
appName: string,
|
const logs = processLogs('deploy', { appName });
|
||||||
onLog?: (log: LogEntry) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const logs = processLogs("deploy", { appName });
|
|
||||||
|
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
await delay(200);
|
await delay(200);
|
||||||
onLog?.(log);
|
onLog?.(log);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async stopApp(
|
async stopApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||||
appName: string,
|
const logs = processLogs('stop', { appName });
|
||||||
onLog?: (log: LogEntry) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const logs = processLogs("stop", { appName });
|
|
||||||
|
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
await delay(150);
|
await delay(150);
|
||||||
onLog?.(log);
|
onLog?.(log);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async upgradeApp(
|
async upgradeApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||||
appName: string,
|
const logs = processLogs('upgrade', { appName, version });
|
||||||
version: string,
|
|
||||||
onLog?: (log: LogEntry) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const logs = processLogs("upgrade", { appName, version });
|
|
||||||
|
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
await delay(200);
|
await delay(200);
|
||||||
onLog?.(log);
|
onLog?.(log);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async removeApp(
|
async removeApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||||
appName: string,
|
const logs = processLogs('remove', { appName });
|
||||||
onLog?: (log: LogEntry) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const logs = processLogs("remove", { appName });
|
|
||||||
|
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
await delay(180);
|
await delay(180);
|
||||||
onLog?.(log);
|
onLog?.(log);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async refreshServer(
|
async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||||
serverName: string,
|
const logs = processLogs('serverRefresh', { serverName });
|
||||||
onLog?: (log: LogEntry) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const logs = processLogs("serverRefresh", { serverName });
|
|
||||||
|
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
await delay(200);
|
await delay(200);
|
||||||
onLog?.(log);
|
onLog?.(log);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async deployAllApps(
|
async deployAllApps(serverName: string, appCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||||
serverName: string,
|
const logs = processLogs('deployAll', { serverName, appCount });
|
||||||
appCount: number,
|
|
||||||
onLog?: (log: LogEntry) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const logs = processLogs("deployAll", { serverName, appCount });
|
|
||||||
|
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
await delay(250);
|
await delay(250);
|
||||||
onLog?.(log);
|
onLog?.(log);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async upgradeAllApps(
|
async upgradeAllApps(serverName: string, upgradeCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
|
||||||
serverName: string,
|
const logs = processLogs('upgradeAll', { serverName, upgradeCount });
|
||||||
upgradeCount: number,
|
|
||||||
onLog?: (log: LogEntry) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const logs = processLogs("upgradeAll", { serverName, upgradeCount });
|
|
||||||
|
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
await delay(250);
|
await delay(250);
|
||||||
onLog?.(log);
|
onLog?.(log);
|
||||||
@ -167,5 +141,5 @@ export const mockApiService = {
|
|||||||
async getRecipes(): Promise<AbraRecipe[]> {
|
async getRecipes(): Promise<AbraRecipe[]> {
|
||||||
await delay(300);
|
await delay(300);
|
||||||
return catalogue as AbraRecipe[];
|
return catalogue as AbraRecipe[];
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
@ -8,7 +8,7 @@ export interface AbraApp {
|
|||||||
recipe: string;
|
recipe: string;
|
||||||
appName: string;
|
appName: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
status: "deployed" | "stopped" | "unknown";
|
status: 'deployed' | 'stopped' | 'unknown';
|
||||||
chaos: string;
|
chaos: string;
|
||||||
chaosVersion: string;
|
chaosVersion: string;
|
||||||
version: string;
|
version: string;
|
||||||
@ -37,16 +37,53 @@ export interface AppWithServer extends AbraApp {
|
|||||||
upgradeCount: 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 {
|
export interface Image {
|
||||||
image: string;
|
image: string;
|
||||||
rating: string;
|
rating: string
|
||||||
source: string;
|
source: string
|
||||||
url: string;
|
url: string
|
||||||
}
|
}
|
||||||
type RecipeVersions = Record<string, Record<string, ServiceMeta>>[];
|
type RecipeVersions = Record<string, Record<string, ServiceMeta>>[];
|
||||||
export interface ServiceMeta {
|
export interface ServiceMeta {
|
||||||
image: string;
|
image: string;
|
||||||
tag: string;
|
tag: string
|
||||||
}
|
}
|
||||||
export interface Features {
|
export interface Features {
|
||||||
backups: string;
|
backups: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user