3 Commits

Author SHA1 Message Date
hey
6ecc158268 add creating new app and fix terminal bug 2026-04-18 17:35:50 -04:00
hey
864640a15e merged recipes with log streaming 2026-04-18 13:32:10 -04:00
hey
9b1eaf168f added service information for each app 2026-04-18 13:28:19 -04:00
44 changed files with 9390 additions and 1396 deletions

5
.env.example Normal file
View 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
View File

@ -8,7 +8,6 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
pnpm-lock.yaml
dist
dist-ssr
*.local

View File

@ -1,53 +1,7 @@
# Coop Cloud Front
# Coop Cloud Front
Frontend for Coop Cloud — a web UI wrapper around the `abra` CLI for
managing VPSs, containers and application deployments.
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.
This repository contains a Vite + React + TypeScript app styled
with SCSS.
## Still a work in progess!
## Quick start
- Install dependencies (pnpm is recommended):
```
pnpm install
pnpm dev
```
The app uses Vite; start the dev server with `pnpm dev`.
## Environment
- The app reads runtime flags from Vite env variables. The default build uses mocked api data.
-To deploy on a real API without mocking, run:
pnpm start:prod
Modify `.env` file at the project root if you need to override values for
development (see Vite docs for env var conventions).
## Scripts
- `pnpm dev` — start dev server
- `pnpm build` — run TypeScript and build production assets
- `pnpm preview` — preview production build locally
- `pnpm lint` — run ESLint
- `pnpm lint:scss` — run Stylelint
- `pnpm format` — format with Prettier
## Notes about local development
- The repo contains mock JSON and a `mockApi` service to develop UI without
a backend. Toggle mock mode with `VITE_MOCK_AUTH=true`.
- Routes are client-side using `react-router-dom`.
## Next steps / suggested work items
- Add form validation and better UX for `RecipeForm`.
- Improve accessibility.
- Add unit tests.
- Improve responsive layouts and card grid behaviour on narrow screens.
- Add deployment docs and production environment variables.
---
## This is built with react, typescript, scss, and vite

View File

@ -5,10 +5,6 @@
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>coopcloud-frontend</title>
<link rel="preload" href="/fonts/manrope.light.woff2" as="font" type="font/woff2">
<link rel="preload" href="/fonts/manrope.medium.woff2" as="font" type="font/woff2">
<link rel="preload" href="/fonts/manrope.bold.woff2" as="font" type="font/woff2">
<link rel="preload" href="/fonts/Lora-Italic.woff2" as="font" type="font/woff2">
</head>
<body>
<div id="root"></div>

5062
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@
"scripts": {
"start": "vite",
"dev": "vite",
"start:prod": "VITE_MOCK_AUTH=false pnpm start",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",

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.

View File

@ -1,26 +1,26 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { Dashboard } from "./routes/Dashboard/Dashboard";
import { Apps } from "./routes/Apps/Apps";
import { AppDetail } from "./routes/Apps/App";
import { Servers } from "./routes/Servers/Servers";
import { Server } from "./routes/Servers/Server";
import { Recipes } from "./routes/Recipes/Recipes";
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { Dashboard } from './routes/Dashboard/Dashboard';
import { Apps } from './routes/Apps/Apps';
import { AppDetail } from './routes/Apps/App';
import { Servers } from './routes/Servers/Servers';
import { Server } from './routes/Servers/Server';
import { Recipes } from './routes/Recipes/Recipes';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/apps" element={<Apps />} />
<Route path="/apps/:server/:appName" element={<AppDetail />} />
<Route path="/servers" element={<Servers />} />
<Route path="/servers/:serverName" element={<Server />} />
<Route path="/recipes" element={<Recipes />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/apps" element={<Apps />} />
<Route path="/apps/:server/:appName" element={<AppDetail />} />
<Route path="/servers" element={<Servers />} />
<Route path="/servers/:serverName" element={<Server />} />
<Route path="/recipes" element={<Recipes />} />
{/* 404 catch-all */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
{/* 404 catch-all */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
);
}

View File

@ -1,30 +1,30 @@
@font-face {
font-family: "Lora";
src: url("/fonts/Lora-Italic.woff2") format("woff2");
font-family: 'Lora';
src: url('https://coopcloud.tech/font/Lora-Italic.woff2') format('woff2');
font-style: italic;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "Manrope";
src: url("/fonts/manrope.light.woff2") format("woff2");
font-family: 'Manrope';
src: url('https://coopcloud.tech/font/manrope.light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Manrope";
src: url("/fonts/manrope.medium.woff2") format("woff2");
font-family: 'Manrope';
src: url('https://coopcloud.tech/font/manrope.medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Manrope";
src: url("/fonts/manrope.bold.woff2") format("woff2");
font-family: 'Manrope';
src: url('https://coopcloud.tech/font/manrope.bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;

View File

@ -1,5 +1,5 @@
@use "./variables" as *;
@use "./mixins" as *;
@use './variables' as *;
@use './mixins' as *;
body {
margin: 0;
@ -75,9 +75,7 @@ body {
align-items: center;
gap: $spacing-lg;
padding: $spacing-xl;
transition:
transform $transition-base,
box-shadow $transition-base;
transition: transform $transition-base, box-shadow $transition-base;
&:hover {
transform: translateY(-2px);
@ -87,7 +85,6 @@ body {
// Modifier classes for colored borders
&.upgrade {
border-left: 4px solid $primary-light;
cursor: none;
}
&.chaos {
@ -237,18 +234,6 @@ body {
color: $text-secondary;
}
// Ensure stat-chip shows primary-dark outline on hover and when active
.stat-chip {
&:hover:not(:disabled),
&.active {
border-color: $primary-dark;
box-shadow: 0 0 0 3px rgba($primary-dark, 0.08);
}
}
.filter-chip {
cursor: default;
}
// Results count
.results-count {
text-align: center;

View File

@ -1,4 +1,4 @@
@use "variables" as *;
@use 'variables' as *;
// Flexbox center
@mixin flex-center {
@ -9,122 +9,25 @@
// Responsive breakpoints
@mixin respond-to($breakpoint) {
@if $breakpoint == "sm" {
@media (min-width: $breakpoint-sm) {
@content;
}
} @else if $breakpoint == "md" {
@media (min-width: $breakpoint-md) {
@content;
}
} @else if $breakpoint == "lg" {
@media (min-width: $breakpoint-lg) {
@content;
}
} @else if $breakpoint == "xl" {
@media (min-width: $breakpoint-xl) {
@content;
}
@if $breakpoint == 'sm' {
@media (min-width: $breakpoint-sm) { @content; }
} @else if $breakpoint == 'md' {
@media (min-width: $breakpoint-md) { @content; }
} @else if $breakpoint == 'lg' {
@media (min-width: $breakpoint-lg) { @content; }
} @else if $breakpoint == 'xl' {
@media (min-width: $breakpoint-xl) { @content; }
}
}
// Card style
@mixin card {
background: $bg-primary;
border-radius: $radius-lg;
box-shadow: $shadow-md;
padding: $spacing-xl;
}
// Ensure cards occupy consistent vertical space and can stretch horizontally
@mixin card-dimensions($min-width: 320px, $min-height: 180px) {
min-width: $min-width;
min-height: $min-height;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
/// Standard vertical stack for card-style list rows (dashboard recent apps, server detail apps, etc.)
@mixin card-list-vertical($gap: $spacing-md) {
display: flex;
flex-direction: column;
gap: $gap;
}
/// Hover lift used by dashboard list cards, server grid cards, and similar surfaces
@mixin card-hover-lift($translate-y: -2px, $hover-shadow: $shadow-lg) {
transition:
transform $transition-base,
box-shadow $transition-base;
&:hover {
transform: translateY($translate-y);
box-shadow: $hover-shadow;
}
}
/// Card shell with horizontal scroll (e.g. data tables)
@mixin scrollable-card {
@include card;
overflow-x: auto;
}
/// Header block inside a card: title area with a bottom rule (server cards, etc.)
@mixin card-header-rule {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
border-bottom: 2px solid $bg-secondary;
}
/// Primary title + muted monospace/meta line (server cards, resource headers)
@mixin card-title-stack(
$title-size: $font-size-xl,
$title-weight: $font-weight-bold,
$meta-size: $font-size-sm
) {
h3 {
margin: 0 0 $spacing-xs;
font-size: $title-size;
color: $text-primary;
font-weight: $title-weight;
}
.server-host {
font-size: $meta-size;
color: $text-muted;
font-family: monospace;
}
}
/// Label + value row inside a card body (server stats, etc.)
@mixin card-stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-sm 0;
border-bottom: 1px solid $bg-secondary;
&:last-child {
border-bottom: none;
}
}
/// Full-width edge bleed for highlighted rows inside padded cards (matches card horizontal padding)
@mixin card-row-highlight-bleed($clear-bottom-border: true) {
padding: $spacing-sm $spacing-md;
margin: $spacing-sm (-$spacing-xl);
padding-left: calc($spacing-xl + $spacing-md);
padding-right: calc($spacing-xl + $spacing-md);
@if $clear-bottom-border {
border-bottom: none;
}
}
// Button base
@mixin button-base {
padding: $spacing-sm $spacing-lg;
@ -133,7 +36,7 @@
font-weight: $font-weight-semibold;
cursor: pointer;
transition: all $transition-base;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
@ -160,68 +63,4 @@
font-weight: $font-weight-semibold;
background-color: rgba($color, 0.1);
// color: darken($color, 20%);
}
// Compact chip used for stat chips and small interactive chips
@mixin chip(
$bg: white,
$border-color: $border-color,
$radius: $radius-md,
$padding: $spacing-md $spacing-lg,
$gap: $spacing-md,
$hover-translate: -2px,
$hover-shadow: $shadow-sm,
$font-size: $font-size-base
) {
display: flex;
align-items: center;
gap: $gap;
padding: $padding;
background: $bg;
border: 2px solid $border-color;
border-radius: $radius;
cursor: pointer;
transition: all $transition-base;
font-size: $font-size;
&:hover:not(:disabled) {
border-color: $primary;
transform: translateY($hover-translate);
box-shadow: $hover-shadow;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// Reusable action button styles; accepts border width and radius
@mixin action-btn(
$border-width: 1px,
$radius: $radius-sm,
$padding: $spacing-xs $spacing-md,
$font-size: $font-size-sm
) {
background: none;
border: $border-width solid $border-color;
padding: $padding;
border-radius: $radius;
cursor: pointer;
font-size: $font-size;
color: $text-primary;
font-weight: $font-weight-medium;
transition: all $transition-base;
&:hover {
background-color: rgba($primary, 0.05);
color: $text-primary;
transform: translateY(-1px);
border-color: $primary;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}

View File

@ -1,6 +1,6 @@
// Colors
$primary: #efefef;
$primary-dark: #6a9cff;
$primary: #EFEFEF;
$primary-dark: #6A9CFF;
$primary-light: #ff4f88;
$secondary: #363636;
@ -10,7 +10,8 @@ $error: #ef4444;
$info: #3b82f6;
$text-primary: #363636;
$text-secondary: #4a4a4a;
$text-secondary: #4a4a4a
;
$text-muted: #999;
$bg-primary: #ffffff;
@ -30,21 +31,11 @@ $spacing-3xl: 4rem;
// Typography
$font-family-body:
"Manrope",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"Roboto",
"Oxygen",
"Ubuntu",
"Cantarell",
"Fira Sans",
"Droid Sans",
"Helvetica Neue",
sans-serif;
$font-family-body: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
$font-family-heading: "Lora", serif;
$font-family-heading: 'Lora', serif;
$font-size-xs: 0.75rem;
$font-size-sm: 0.875rem;
@ -81,4 +72,4 @@ $transition-slow: 0.3s ease;
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
$breakpoint-xl: 1280px;

View File

@ -1,7 +1,7 @@
// 1. Configuration & helpers (no CSS output)
@use "variables";
@use "mixins";
@use "fonts";
@use 'variables';
@use 'mixins';
@use 'fonts';
// 2. Global base styles
@use "global";
@use 'global';

View File

@ -1,7 +1,8 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import "./_Header.scss";
import logo from "../../assets/coopcloud_logo_grey.svg";
import React from 'react';
import { useNavigate } from 'react-router-dom';
import './_Header.scss';
import logo from '../../assets/coopcloud_logo_grey.svg';
interface HeaderProps {
children: React.ReactNode;
@ -9,24 +10,26 @@ interface HeaderProps {
export const Header: React.FC<HeaderProps> = ({ children }) => {
const navigate = useNavigate();
return (
<header className="layout-header">
<div className="header-content">
<h1 onClick={() => navigate("/dashboard")} className="logo">
<img className="logo" src={logo} />
</h1>
<nav className="nav">
<button onClick={() => navigate("/apps")} className="nav-link">
Apps
</button>
<button onClick={() => navigate("/servers")} className="nav-link">
Servers
</button>
<button onClick={() => navigate("/recipes")} className="nav-link">
Recipes
</button>
</nav>
</div>
</header>
);
};
return(
<header className="layout-header">
<div className="header-content">
<h1 onClick={() => navigate('/dashboard')} className="logo">
<img className="logo" src={logo}/>
</h1>
<nav className="nav">
<button onClick={() => navigate('/dashboard')} className="nav-link">
Dashboard
</button>
<button onClick={() => navigate('/apps')} className="nav-link">
Apps
</button>
<button onClick={() => navigate('/servers')} className="nav-link">
Servers
</button>
<button onClick={() => navigate('/recipes')} className="nav-link">
Recipes
</button>
</nav>
</div>
</header>
)}

View File

@ -1,5 +1,5 @@
@use "../../assets/scss/variables" as *;
@use "../../assets/scss/mixins" as *;
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
.layout-header {
background-color: $primary-light;
@ -27,14 +27,12 @@
margin: 0;
display: inline-block;
max-width: 100%;
cursor: pointer;
}
.nav {
display: flex;
gap: $spacing-sm;
flex: 1;
text-align: center;
@media (max-width: 768px) {
width: 100%;
@ -65,7 +63,7 @@
// Active indicator
&.active::after {
content: "";
content: '';
position: absolute;
bottom: 0;
left: 50%;
@ -80,4 +78,4 @@
font-size: $font-size-xl;
}
}
}
}

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import type { LogEntry } from "../../services/mockApi"; // Import from mockApi (or api for real)
import "./_Terminal.scss";
import React, { useEffect, useRef, useState } from 'react';
import type { LogEntry } from '../../services/mockApi'; // Import from mockApi (or api for real)
import './_Terminal.scss';
interface TerminalProps {
logs: LogEntry[];
@ -8,44 +8,34 @@ interface TerminalProps {
onClose?: () => void;
}
export const Terminal: React.FC<TerminalProps> = ({
logs,
isActive,
onClose,
}) => {
export const Terminal: React.FC<TerminalProps> = ({ logs, isActive, onClose }) => {
const terminalRef = useRef<HTMLDivElement>(null);
const indexRef = useRef(0);
const [displayedLogs, setDisplayedLogs] = useState<LogEntry[]>([]);
// Stream logs in with delays for realistic effect
useEffect(() => {
if (!isActive || logs.length === 0) {
indexRef.current = 0;
setDisplayedLogs([]);
return;
}
setDisplayedLogs([]);
let currentIndex = 0;
const streamLogs = () => {
if (currentIndex < logs.length) {
setDisplayedLogs((prev) => [...prev, logs[currentIndex]]);
currentIndex++;
if (indexRef.current < logs.length) {
setDisplayedLogs(prev => [...prev, logs[indexRef.current]]);
indexRef.current++;
// Variable delay based on log type
const delay =
logs[currentIndex - 1]?.type === "command"
? 200
: logs[currentIndex - 1]?.type === "output"
? 100
: 300;
const delay = logs[indexRef.current - 1]?.type === 'command' ? 200 :
logs[indexRef.current - 1]?.type === 'output' ? 100 : 300;
setTimeout(streamLogs, delay);
}
};
streamLogs();
}, [logs, isActive]);
// Auto-scroll to bottom
useEffect(() => {
if (terminalRef.current) {
@ -58,11 +48,11 @@ export const Terminal: React.FC<TerminalProps> = ({
}
const formatTime = (date: Date) => {
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '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-spacer"></div>
</div>
<div className="terminal-content" ref={terminalRef}>
{displayedLogs
.filter((log) => log && log.type)
.map((log, index) => (
<div key={index} className={`terminal-line terminal-${log.type}`}>
<span className="terminal-timestamp">
[{formatTime(log.timestamp)}]
</span>
<span className="terminal-text">{log.text}</span>
</div>
))}
{displayedLogs.filter(log => log && log.type).map((log, index) => (
<div key={index} className={`terminal-line terminal-${log.type}`}>
<span className="terminal-text">{log.text}</span>
</div>
))}
{isActive && displayedLogs.length > 0 && (
<div className="terminal-cursor"></div>
)}

View File

@ -1,14 +1,12 @@
@use "../../assets/scss/variables" as *;
@use "../../assets/scss/mixins" as *;
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
.terminal-container {
background: #1e1e1e;
border-radius: $radius-md;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
font-family:
"SF Mono", "Monaco", "Inconsolata", "Fira Code", "Droid Sans Mono",
"Source Code Pro", monospace;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
margin-bottom: $spacing-xl;
animation: terminal-appear 0.3s ease-out;
}
@ -183,12 +181,10 @@
}
@keyframes terminal-cursor-blink {
0%,
50% {
0%, 50% {
opacity: 1;
}
51%,
100% {
51%, 100% {
opacity: 0;
}
}
@ -205,4 +201,4 @@
display: none;
}
}
}
}

View File

@ -1,24 +1,14 @@
@use "./assets/scss/variables" as *;
@use "./assets/scss/mixins" as *;
@use "./assets/scss/global" as *;
@use './assets/scss/variables' as *;
@use './assets/scss/mixins' as *;
@use './assets/scss/global' as *;
// Global root styles
:root {
line-height: 1.5;
font-weight: 400;
font-family:
"Manrope",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"Roboto",
"Oxygen",
"Ubuntu",
"Cantarell",
"Fira Sans",
"Droid Sans",
"Helvetica Neue",
sans-serif;
font-family: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
color-scheme: light dark;
color: $text-primary;
@ -54,12 +44,7 @@ a {
}
// Global heading styles
h1,
h2,
h3,
h4,
h5,
h6 {
h1, h2, h3, h4, h5, h6 {
color: $text-primary;
margin: 0;
}

View File

@ -1,10 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.scss";
import App from "./App.tsx";
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.scss'
import App from './App.tsx'
createRoot(document.getElementById("root")!).render(
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
)

View File

@ -1,7 +1,6 @@
@use "../../assets/scss/variables" as *;
@use "../../assets/scss/mixins" as *;
@use "../../assets/scss/global" as *;
@use "sass:color";
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
.app-detail-page {
@extend .page-wrapper;
@ -48,11 +47,29 @@
// Action buttons
.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 {
background: $primary;
color: $text-primary;
color: white;
border-color: $primary;
&:hover:not(:disabled) {
@ -60,11 +77,20 @@
border-color: $primary-light;
}
}
&.service {
&:hover:not(:disabled) {
background: #3fff5c9d;
}
}
&.secondary {
background: $bg-secondary;
color: $text-primary;
border-color: $border-color;
&:hover:not(:disabled) {
background: darken($bg-secondary, 5%);
}
}
&.danger {
@ -73,7 +99,7 @@
border-color: $error;
&: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 {
display: grid;
@ -142,13 +211,13 @@
}
.chaos-active {
background: color.adjust($info, $lightness: -10%);
color: darken($info, 10%);
font-weight: $font-weight-medium;
}
}
.domain-link {
color: $primary-dark;
color: $primary;
text-decoration: none;
font-size: $font-size-base;
transition: color $transition-base;
@ -162,7 +231,7 @@
.server-link {
background: none;
border: none;
color: $primary-dark;
color: $primary;
cursor: pointer;
padding: 0;
font-size: $font-size-base;
@ -265,7 +334,7 @@
transition: all $transition-base;
&:hover:not(:disabled) {
background: color.adjust($warning, $lightness: -10%);
background: darken($warning, 10%);
transform: translateY(-1px);
}
@ -280,7 +349,7 @@
.version-latest {
padding: $spacing-md;
background: rgba($success, 0.1);
background: color.adjust($success, $lightness: -10%);
color: darken($success, 10%);
border-radius: $radius-md;
text-align: center;
font-size: $font-size-base;
@ -326,7 +395,6 @@
background: rgba($error, 0.05);
}
}
.action-text {
flex: 1;
font-weight: $font-weight-medium;

View File

@ -1,115 +1,200 @@
import React, { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Header } from "../../components/Header/Header";
import { Terminal } from "../../components/Terminal/Terminal";
import { apiService } from "../../services/api";
import type { AbraApp } from "../../types";
import type { LogEntry } from "../../services/mockApi";
import "./App.scss";
import React, { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '../../components/Header/Header';
import { Terminal } from '../../components/Terminal/Terminal';
import { apiService } from '../../services/api';
import type { AbraApp, AbraAppService, AbraServiceState } from '../../types';
import type { LogEntry } from '../../services/mockApi';
import './App.scss';
export const AppDetail: React.FC = () => {
const { server, appName } = useParams<{ server: string; appName: string }>();
const navigate = useNavigate();
const [app, setApp] = useState<AbraApp | null>(null);
const [deployState, setDeployState] = useState("undeployed | deploying | deployed | failed");
const [serviceState, setServiceState] = useState<Record<string, AbraServiceState>>({});
const [services, setServices] = useState<AbraAppService[]>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState<string | null>(null);
// Terminal state
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
const [terminalActive, setTerminalActive] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === "true";
// 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(() => {
const fetchApp = async () => {
try {
if (isMockMode) {
const { mockApiService } = await import("../../services/mockApi");
const { mockApiService } = await import('../../services/mockApi');
const appsData = await mockApiService.getAppsGrouped();
const serverApps = appsData[server || ""];
const foundApp = serverApps?.apps.find((a) => a.appName === appName);
const serverApps = appsData[server || ''];
const foundApp = serverApps?.apps.find(a => a.appName === appName);
if (foundApp) {
setApp(foundApp);
} else {
setError("App not found");
setError('App not found');
}
} else {
console.log('fetching app...');
const appsData = await apiService.getAppsGrouped();
const serverApps = appsData[server || ""];
const foundApp = serverApps?.apps.find((a) => a.appName === appName);
const serverApps = appsData[server || ''];
const foundApp = serverApps?.apps.find(a => a.appName === appName);
if (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 {
setError("App not found");
setError('App not found');
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load app");
setError(err instanceof Error ? err.message : 'Failed to load app');
} finally {
setLoading(false);
}
};
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) => {
if (!app) return;
setActionLoading(action);
setTerminalActive(true);
setTerminalLogs([]);
try {
if (isMockMode) {
const { mockApiService } = await import("../../services/mockApi");
const { mockApiService } = await import('../../services/mockApi');
const onLog = (log: LogEntry) => {
setTerminalLogs((prev) => [...prev, log]);
setTerminalLogs(prev => [...prev, log]);
};
switch (action) {
case "deploy":
case 'deploy':
await mockApiService.deployApp(app.appName, onLog);
break;
case "stop":
case 'stop':
await mockApiService.stopApp(app.appName, onLog);
break;
case "upgrade":
case 'upgrade':
if (version) {
await mockApiService.upgradeApp(app.appName, version, onLog);
}
break;
case "remove":
case 'remove':
await mockApiService.removeApp(app.appName, onLog);
break;
}
} else {
// Real API calls
switch (action) {
case "stop":
case 'stop':
await apiService.stopApp(app.appName);
setRefreshKey(prev => prev + 1);
break;
case "deploy":
case 'deploy':
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;
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) {
console.error("Action failed:", err);
setTerminalLogs((prev) => [
...prev,
{
type: "error",
text: `❌ Error: ${err instanceof Error ? err.message : "Action failed"}`,
timestamp: new Date(),
},
]);
console.error('Action failed:', err);
setTerminalLogs(prev => [...prev, {
type: 'error',
text: `❌ Error: ${err instanceof Error ? err.message : 'Action failed'}`,
timestamp: new Date()
}]);
} finally {
setActionLoading(null);
}
@ -131,24 +216,23 @@ export const AppDetail: React.FC = () => {
<div className="app-detail-page">
<Header />
<main className="app-detail-content">
<div className="error">{error || "App not found"}</div>
<button onClick={() => navigate("/apps")} className="back-button">
<div className="error">{error || 'App not found'}</div>
<button onClick={() => navigate('/apps')} className="back-button">
Back to Apps
</button>
</main>
</div>
);
}
const upgradeVersions =
app.upgrade !== "latest" ? app.upgrade.split("\n") : [];
// TODO: make sure this makes sense when app.upgrade is unknown
const upgradeVersions = (app.upgrade !== 'latest' && app.upgrade !== 'unknown') ? app.upgrade.split('\n') : [];
return (
<div className="app-detail-page">
<Header />
<main className="app-detail-content">
<div className="breadcrumb">
<button onClick={() => navigate("/apps")} className="breadcrumb-link">
<button onClick={() => navigate('/apps')} className="breadcrumb-link">
Apps
</button>
<span className="breadcrumb-separator">/</span>
@ -160,41 +244,40 @@ export const AppDetail: React.FC = () => {
<h1>{app.appName}</h1>
<div className="app-meta">
<span className="recipe-badge">{app.recipe}</span>
<span className={`status-badge status-${app.status}`}>
{app.status}
</span>
{app.chaos === "true" && (
<span className="chaos-badge" title="Chaos mode enabled">
{" "}
Chaos
</span>
<span className={`status-badge status-${app.status}`}>{app.status}</span>
{app.chaos === 'true' && (
<span className="chaos-badge" title="Chaos mode enabled">🔬 Chaos</span>
)}
</div>
</div>
<div className="app-actions">
<button
<button
className="action-btn danger"
onClick={() => handleAction("stop")}
onClick={() => handleAction('stop')}
disabled={!!actionLoading}
>
{actionLoading === "stop" ? "Stopping..." : "Stop"}
{actionLoading === 'stop' ? 'Stopping...' : 'Stop'}
</button>
<button
<button
className="action-btn primary"
onClick={() => handleAction("deploy")}
onClick={() => handleAction('deploy')}
disabled={!!actionLoading}
>
{actionLoading === "deploy" ? "Deploying..." : "Deploy"}
{actionLoading === 'deploy' ? 'Deploying...' : 'Deploy'}
</button>
</div>
</div>
{/* Terminal Component */}
<Terminal
logs={terminalLogs}
<Terminal
logs={terminalLogs}
isActive={terminalActive}
onClose={() => setTerminalActive(false)}
onClose={() => {
stopRef.current?.();
stopRef.current = null;
setTerminalActive(false)
}}
/>
<div className="content-grid">
@ -202,7 +285,7 @@ export const AppDetail: React.FC = () => {
<div className="main-column">
<section className="info-card">
<h2>Application Details</h2>
<div className="info-grid">
<div className="info-item">
<label>App Name</label>
@ -212,12 +295,7 @@ export const AppDetail: React.FC = () => {
<div className="info-item">
<label>Domain</label>
{app.domain ? (
<a
href={`https://${app.domain}`}
target="_blank"
rel="noopener noreferrer"
className="link"
>
<a href={`https://${app.domain}`} target="_blank" rel="noopener noreferrer" className="domain-link">
{app.domain}
</a>
) : (
@ -226,14 +304,13 @@ export const AppDetail: React.FC = () => {
</div>
<div className="info-item">
<label htmlFor="server-link">Server</label>
<Link
id="server-link"
to={`/servers/${app.server}`}
className="link"
<label>Server</label>
<button
onClick={() => navigate(`/servers/${app.server}`)}
className="server-link"
>
{app.server}
</Link>
{app.server}
</button>
</div>
<div className="info-item">
@ -250,23 +327,86 @@ export const AppDetail: React.FC = () => {
<div className="info-item">
<label>Chaos Mode</label>
<span className={app.chaos === "true" ? "chaos-active" : ""}>
{app.chaos === "true" ? "☠️ Enabled" : "Disabled"}
<span className={app.chaos === 'true' ? 'chaos-active' : ''}>
{app.chaos === 'true' ? '🔬 Enabled' : 'Disabled'}
</span>
</div>
</div>
</section>
{(deployState === "deployed" || deployState === "failed") && (
<section className="info-card">
<h2> Service Information </h2>
<div className="service-grid">
{services === undefined || services?.length === 0 ? (
<div className="no-services"> Loading services... </div>
) : (
services.map((service) => (
<div
key={service.service}
className={getServiceClass(service.state, service.status)}
>
<div className="service-name">
<h3> {service.service} </h3>
</div>
<div className="service-row">
<span className="service-value">{service.state}</span>
<span className="service-value">{service.status}</span>
</div>
<button
className="action-btn"
onClick={() => handleAction('logs', service.service)}
disabled={!!actionLoading}
>
Logs
</button>
</div>
))
)}
</div>
</section>)}
{(deployState === "deploying") && (
<section className="info-card">
<h2> Service Information </h2>
<div className="service-grid">
{serviceState === undefined || Object.keys(serviceState).length === 0 ? (
<div className="no-services"> Preparing services... </div>
) : (
Object.keys(serviceState).map((name) => (
<div
key={name}
className={getServiceClass(serviceState[name].status, serviceState[name].health)}
>
<div className="service-name">
<h3> {name} </h3>
</div>
<div className="service-row">
<span className="service-value">{serviceState[name].status}</span>
<span className="service-value">{serviceState[name].health}</span>
</div>
<button
className="action-btn"
onClick={() => handleAction('logs', appName)}
disabled={!!actionLoading}
>
Logs
</button>
</div>
))
)}
</div>
</section>)}
<section className="info-card">
<h2>Version Information</h2>
<div className="version-info">
<div className="version-current">
<label>Current Version</label>
<code>{app.version}</code>
</div>
{app.chaosVersion !== "unknown" && (
{app.chaosVersion !== 'unknown' && (
<div className="version-current">
<label>Chaos Version</label>
<code>{app.chaosVersion}</code>
@ -277,22 +417,18 @@ export const AppDetail: React.FC = () => {
<div className="version-upgrades">
<label>
Available Upgrades
<span className="upgrade-count">
{upgradeVersions.length}
</span>
<span className="upgrade-count">{upgradeVersions.length}</span>
</label>
<div className="upgrade-list">
{upgradeVersions.map((version, idx) => (
<div key={idx} className="upgrade-item">
<code>{version}</code>
<button
<button
className="upgrade-btn"
onClick={() => handleAction("upgrade", version)}
onClick={() => handleAction('upgrade', version)}
disabled={!!actionLoading}
>
{actionLoading === "upgrade"
? "Upgrading..."
: "Upgrade"}
{actionLoading === 'upgrade' ? 'Upgrading...' : 'Upgrade'}
</button>
</div>
))}
@ -300,8 +436,22 @@ export const AppDetail: React.FC = () => {
</div>
)}
{app.upgrade === "latest" && (
<div className="version-latest"> Running latest version</div>
{app.upgrade === 'unknown' && (
<div className="version-upgrades">
<label>
Available Upgrades
</label>
<div className="upgrade-list">
<div className="upgrade-item">
<code>None</code>
</div>
</div>
</div>
)}
{app.upgrade === 'latest' && (
<div className="version-latest">
Running latest version
</div>
)}
</div>
</section>
@ -311,31 +461,30 @@ export const AppDetail: React.FC = () => {
<div className="sidebar-column">
<section className="info-card">
<h2>Quick Actions</h2>
<div className="action-list">
<button
<button
className="action-list-item"
onClick={() => handleAction("deploy")}
onClick={() => handleAction('deploy')}
disabled={!!actionLoading}
>
<span className="action-text">Deploy Application</span>
</button>
<button
<button
className="action-list-item"
onClick={() => handleAction("stop")}
onClick={() => handleAction('stop')}
disabled={!!actionLoading}
>
<span className="action-text">Stop Application</span>
</button>
<button
<button
className="action-list-item danger"
onClick={() => {
if (
confirm(`Are you sure you want to remove ${app.appName}?`)
) {
handleAction("remove");
if (confirm(`Are you sure you want to remove ${app.appName}?`)) {
handleAction('remove');
}
}}
disabled={!!actionLoading}

View File

@ -1,6 +1,6 @@
@use "../../assets/scss/variables" as *;
@use "../../assets/scss/mixins" as *;
@use "../../assets/scss/global" as *;
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
// Extend global page wrapper
.apps-page {
@ -11,44 +11,10 @@
@extend .page-content;
}
// Compact stats row
.stats-row {
display: flex;
align-items: center;
gap: $spacing-md;
margin-bottom: $spacing-xl;
flex-wrap: wrap;
}
.stat-chip {
@include chip();
&.active {
border-color: $primary-dark;
background: $bg-primary;
box-shadow: 0 0 0 3px rgba($primary-dark, 0.08);
}
.stat-label {
color: $text-secondary;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
}
.stat-value {
color: $text-primary;
font-size: $font-size-xl;
font-weight: $font-weight-bold;
min-width: 24px;
text-align: center;
}
}
/* reset-filters-btn removed — outline on stat-chip indicates active filters */
// Apps table specific styles
.apps-table-container {
@include scrollable-card;
@include card;
overflow-x: auto;
margin-bottom: $spacing-lg;
}
@ -105,15 +71,16 @@
font-weight: $font-weight-medium;
color: $text-primary;
}
.domain-link {
color: $primary-dark;
text-decoration: none;
transition: color $transition-base;
}
&:hover {
color: $primary-light;
text-decoration: underline;
}
.domain-link {
color: $primary;
text-decoration: none;
transition: color $transition-base;
&:hover {
color: $primary-light;
text-decoration: underline;
}
}
@ -145,27 +112,23 @@
gap: $spacing-sm;
.action-btn {
@include action-btn(
1px,
$radius-sm,
$spacing-xs $spacing-md,
$font-size-sm
);
background: none;
border: 1px solid $border-color;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-sm;
cursor: pointer;
font-size: $font-size-base;
color: $text-primary;
transition: all $transition-base;
&:hover {
background-color: $primary;
color: white;
border-color: $primary;
background-color: $bg-tertiary;
transform: scale(1.1);
}
&.upgrade {
border-color: $warning;
color: $warning;
&:hover {
background-color: $warning;
color: white;
}
}
}
}

View File

@ -1,30 +1,27 @@
// TODOS:
// make the two filters non-exlusive
import React, { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Header } from "../../components/Header/Header";
import { apiService } from "../../services/api";
import type { AbraApp, AppWithServer, ServerAppsResponse } from "../../types";
import "./Apps.scss";
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Header } from '../../components/Header/Header';
import { apiService } from '../../services/api';
import type { AbraApp, AppWithServer, ServerAppsResponse } from '../../types';
import './Apps.scss';
export const Apps: React.FC = () => {
const navigate = useNavigate();
const [appsData, setAppsData] = useState<ServerAppsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchTerm, setSearchTerm] = useState("");
const [filterServer, setFilterServer] = useState<string>("all");
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [filterServer, setFilterServer] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [showUpgradesOnly, setShowUpgradesOnly] = useState(false);
const [showChaosOnly, setShowChaosOnly] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === "true";
const isMockMode = false;
useEffect(() => {
const fetchData = async () => {
try {
if (isMockMode) {
const { mockApiService } = await import("../../services/mockApi");
const { mockApiService } = await import('../../services/mockApi');
const data = await mockApiService.getAppsGrouped();
setAppsData(data);
} else {
@ -32,7 +29,7 @@ export const Apps: React.FC = () => {
setAppsData(data);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load apps");
setError(err instanceof Error ? err.message : 'Failed to load apps');
} finally {
setLoading(false);
}
@ -44,15 +41,15 @@ export const Apps: React.FC = () => {
// Flatten and enrich apps data
const allApps: AppWithServer[] = useMemo(() => {
if (!appsData) return [];
return Object.entries(appsData).flatMap(([serverName, serverData]) =>
serverData.apps.map((app) => ({
serverData.apps.map(app => ({
...app,
serverStats: {
appCount: serverData.appCount,
upgradeCount: serverData.upgradeCount,
},
})),
}))
);
}, [appsData]);
@ -62,37 +59,33 @@ export const Apps: React.FC = () => {
return Object.keys(appsData);
}, [appsData]);
// Filter apps (additive filters: upgrades AND chaos can be applied together)
// Filter apps
const filteredApps = useMemo(() => {
return allApps.filter((app) => {
const matchesSearch =
return allApps.filter(app => {
const matchesSearch =
app.appName.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.recipe.toLowerCase().includes(searchTerm.toLowerCase()) ||
(app.domain || "").toLowerCase().includes(searchTerm.toLowerCase());
const matchesServer =
filterServer === "all" || app.server === filterServer;
const matchesChaos = !showChaosOnly || app.chaos === "true";
const matchesUpgrade = !showUpgradesOnly || app.upgrade !== "latest";
(app.domain || '').toLowerCase().includes(searchTerm.toLowerCase());
const matchesServer = filterServer === 'all' || app.server === filterServer;
const matchesChaos = filterStatus === 'all' ||
(filterStatus === 'chaos' && app.chaos === 'true') ||
(filterStatus === 'stable' && app.chaos === 'false');
const matchesUpgrade = !showUpgradesOnly || app.upgrade !== 'latest';
return matchesSearch && matchesServer && matchesChaos && matchesUpgrade;
});
}, [allApps, searchTerm, filterServer, showUpgradesOnly, showChaosOnly]);
}, [allApps, searchTerm, filterServer, filterStatus, showUpgradesOnly]);
const stats = useMemo(() => {
const total = allApps.length;
const needsUpgrade = allApps.filter(
(app) => app.upgrade !== "latest",
).length;
const chaosApps = allApps.filter((app) => app.chaos === "true").length;
const needsUpgrade = allApps.filter(app => app.upgrade !== 'latest').length;
const chaosApps = allApps.filter(app => app.chaos === 'true').length;
const totalServers = servers.length;
return { total, needsUpgrade, chaosApps, totalServers };
}, [allApps, servers]);
const toggleUpgrades = () => setShowUpgradesOnly((prev) => !prev);
const toggleChaos = () => setShowChaosOnly((prev) => !prev);
if (loading) {
return (
<div className="apps-page">
@ -121,41 +114,35 @@ export const Apps: React.FC = () => {
<main className="apps-content">
<div className="page-header">
<h1>Applications</h1>
<p className="subtitle">
{stats.total} apps across {stats.totalServers} servers
</p>
<p className="subtitle">{stats.total} apps across {stats.totalServers} servers</p>
</div>
{/* Compact Stats Overview */}
<div className="stats-row">
<button
className="stat-chip"
onClick={() => navigate("/servers")}
title="View servers"
>
<span className="stat-label">Servers</span>
<span className="stat-value">{stats.totalServers}</span>
</button>
<button
className={`stat-chip filter-chip ${showUpgradesOnly ? "active" : ""}`}
onClick={toggleUpgrades}
title="Click to toggle apps with upgrades available"
disabled={stats.needsUpgrade === 0}
>
<span className="stat-label">Upgrades</span>
<span className="stat-value">{stats.needsUpgrade}</span>
</button>
<button
className={`stat-chip filter-chip ${showChaosOnly ? "active" : ""}`}
onClick={toggleChaos}
title="Click to toggle chaos mode apps"
disabled={stats.chaosApps === 0}
>
<span className="stat-label">Chaos</span>
<span className="stat-value">{stats.chaosApps}</span>
</button>
{/* 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 Apps</p>
</div>
</div>
<div className="stat-card upgrade">
<div className="stat-info">
<p className="stat-number">{stats.needsUpgrade}</p>
<p className="stat-label">Upgrades Available</p>
</div>
</div>
<div className="stat-card chaos">
<div className="stat-info">
<p className="stat-number">{stats.chaosApps}</p>
<p className="stat-label">Chaos Mode</p>
</div>
</div>
<div className="stat-card">
<div className="stat-info">
<p className="stat-number">{stats.totalServers}</p>
<p className="stat-label">Servers</p>
</div>
</div>
</div>
{/* Filters */}
@ -168,17 +155,27 @@ export const Apps: React.FC = () => {
className="search-input"
/>
<select
value={filterServer}
onChange={(e) => setFilterServer(e.target.value)}
>
<select value={filterServer} onChange={(e) => setFilterServer(e.target.value)}>
<option value="all">All Servers</option>
{servers.map((server) => (
<option key={server} value={server}>
{server}
</option>
{servers.map(server => (
<option key={server} value={server}>{server}</option>
))}
</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>
{/* Apps Table */}
@ -192,7 +189,7 @@ export const Apps: React.FC = () => {
<th>Server</th>
<th>Version</th>
<th>Status</th>
{/* <th>Actions</th> */}
<th>Actions</th>
</tr>
</thead>
<tbody>
@ -204,12 +201,10 @@ export const Apps: React.FC = () => {
</tr>
) : (
filteredApps.map((app) => (
<tr
<tr
key={`${app.server}/${app.appName}`}
onClick={() =>
navigate(`/apps/${app.server}/${app.appName}`)
}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/apps/${app.server}/${app.appName}`)}
style={{ cursor: 'pointer' }}
>
<td className="app-name-cell">
<span className="app-name">{app.appName}</span>
@ -219,10 +214,10 @@ export const Apps: React.FC = () => {
</td>
<td>
{app.domain ? (
<a
href={`https://${app.domain}`}
target="_blank"
rel="noopener noreferrer"
<a
href={`https://${app.domain}`}
target="_blank"
rel="noopener noreferrer"
className="domain-link"
onClick={(e) => e.stopPropagation()}
>
@ -238,17 +233,12 @@ export const Apps: React.FC = () => {
<td>
<div className="version-cell">
<span className="version">{app.version}</span>
{app.chaos === "true" && (
<span
className="chaos-badge"
title="Chaos mode enabled"
></span>
{app.chaos === 'true' && (
<span className="chaos-badge" title="Chaos mode enabled"></span>
)}
{app.upgrade !== "latest" && (
<span
className="upgrade-available"
title="Upgrade available"
></span>
{app.upgrade !== 'latest' && (
<span className="upgrade-available" title="Upgrade available">
</span>
)}
</div>
</td>
@ -257,7 +247,7 @@ export const Apps: React.FC = () => {
{app.status}
</span>
</td>
{/* <td>
<td>
<div className="actions">
<button
className="action-btn"
@ -282,7 +272,7 @@ export const Apps: React.FC = () => {
</button>
)}
</div>
</td> */}
</td>
</tr>
))
)}
@ -296,4 +286,4 @@ export const Apps: React.FC = () => {
</main>
</div>
);
};
};

View File

@ -1,34 +1,32 @@
import React, { useEffect, useState } from "react";
import { Header } from "../../components/Header/Header";
import { useNavigate } from "react-router-dom";
import { apiService } from "../../services/api";
import type { AbraApp, AbraServer } from "../../types";
import "./_Dashboard.scss";
import React, { useEffect, useState } from 'react';
import { Header } from '../../components/Header/Header';
import { useNavigate } from 'react-router-dom';
import { apiService } from '../../services/api';
import type { AbraApp, AbraServer } from '../../types';
import './_Dashboard.scss';
export const Dashboard: React.FC = () => {
const [apps, setApps] = useState<AbraApp[]>([]);
const [servers, setServers] = useState<AbraServer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [error, setError] = useState('');
const navigate = useNavigate();
const isMockMode = import.meta.env.VITE_MOCK_AUTH === "true";
const isMockMode = false;
useEffect(() => {
const fetchData = async () => {
try {
if (isMockMode) {
// Use mock API in development
const { mockApiService } = await import("../../services/mockApi");
const { mockApiService } = await import('../../services/mockApi');
const [appsData, serversData] = await Promise.all([
mockApiService.getAppsGrouped(),
mockApiService.getServers(),
]);
// Flatten the grouped apps data
const flatApps = Object.values(appsData).flatMap(
(serverData) => serverData.apps,
);
const flatApps = Object.values(appsData).flatMap(serverData => serverData.apps);
setApps(flatApps);
setServers(serversData);
} else {
@ -37,16 +35,14 @@ export const Dashboard: React.FC = () => {
apiService.getAppsGrouped(),
apiService.getServers(),
]);
// Flatten the grouped apps data
const flatApps = Object.values(appsData).flatMap(
(serverData) => serverData.apps,
);
const flatApps = Object.values(appsData).flatMap(serverData => serverData.apps);
setApps(flatApps);
setServers(serversData);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data");
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
@ -56,8 +52,8 @@ export const Dashboard: React.FC = () => {
}, [isMockMode]);
// Calculate stats
const deployedAppsCount = apps.filter((a) => a.status === "deployed").length;
const serversWithAppsCount = new Set(apps.map((a) => a.server)).size;
const deployedAppsCount = apps.filter(a => a.status === 'deployed').length;
const serversWithAppsCount = new Set(apps.map(a => a.server)).size;
if (loading) {
return (
@ -88,25 +84,25 @@ export const Dashboard: React.FC = () => {
<div className="page-header">
<h1>Dashboard</h1>
</div>
<div className="stats-grid">
<button
onClick={() => navigate("/apps")}
className="nav-link bland-button"
>
<button onClick={() => navigate('/apps')} className="nav-link bland-button">
<div className="stat-card">
<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>
</button>
<button
onClick={() => navigate("/servers")}
className="nav-link bland-button"
>
<button onClick={() => navigate('/servers')} className="nav-link bland-button">
<div className="stat-card">
<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>
</button>
</div>
@ -115,14 +111,14 @@ export const Dashboard: React.FC = () => {
<h3>Recent Applications</h3>
<div className="apps-list">
{apps.slice(0, 5).map((app, index) => (
<div
key={`${app.server}-${app.appName}-${index}`}
className="app-item"
<div
key={`${app.server}-${app.appName}-${index}`}
className="app-item"
onClick={() => navigate(`/apps/${app.server}/${app.appName}`)}
>
<div className="app-info">
<h4>{app.appName}</h4>
<p className="app-domain">{app.domain || "No domain"}</p>
<p className="app-domain">{app.domain || 'No domain'}</p>
<p className="app-server">{app.server}</p>
</div>
<span className={`status-badge status-${app.status}`}>

View File

@ -1,6 +1,6 @@
@use "../../assets/scss/variables" as *;
@use "../../assets/scss/mixins" as *;
@use "../../assets/scss/global" as *;
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
// Extend global page wrapper
.dashboard-page {
@ -23,21 +23,29 @@
}
.apps-list {
@include card-list-vertical;
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.app-item {
@include card;
@include card-hover-lift(-2px, $shadow-lg);
display: flex;
justify-content: space-between;
align-items: center;
transition: transform $transition-base, box-shadow $transition-base;
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-lg;
}
.app-info {
flex: 1;
h4 {
margin: 0 0 $spacing-xs;
font-size: $font-size-lg;
color: $text-primary;
font-weight: $font-weight-semibold;

View File

@ -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%;
}

View File

@ -1,66 +1,69 @@
import { useEffect, useState } from "react";
import { apiService } from "../../services/api";
import type { AbraServer } from "../../types";
import "./RecipeForm.scss";
import { useState, useEffect } from "react";
import { apiService } from '../../services/api';
import type { AbraServer } from '../../types';
function RecipeForm({ recipe, onClose }) {
const [loading, setLoading] = useState(true);
const [servers, setServers] = useState<AbraServer[]>([]);
const [selectedServer, setSelectedServer] = useState(""); // ❌ only one value
const [chaos, setChaos] = useState(false);
const [secrets, setSecrets] = useState(false);
const [error, setError] = useState("");
const [error, setError] = useState('');
useEffect(() => {
const fetchData = async () => {
try {
const [serversData] = await Promise.all([apiService.getServers()]);
setServers(serversData);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load servers");
} finally {
setLoading(false);
}
};
fetchData();
});
const fetchData = async () => {
try {
if (servers.length === 0) {
const [serversData] = await Promise.all([
apiService.getServers(),
]);
setServers(serversData);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load servers');
} finally {
setLoading(false);
}
};
fetchData();
});
const [formData, setFormData] = useState({
server: "",
domain: "",
chaos: false,
secrets: true,
secrets: false,
});
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
const {name, type, value, checked} = e.target;
setFormData((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
console.log(formData);
};
const handleSubmit = (e) => {
e.preventDefault();
console.log("Submitting:", formData);
apiService.newApp(recipe.name, formData);
onClose();
};
return (
<form className="recipe-form" onSubmit={handleSubmit}>
<form onSubmit={handleSubmit}>
<h2>{recipe.name}</h2>
<p className="form-subtitle">Configure and deploy this recipe.</p>
{error && <div className="form-error">{error}</div>}
{loading ? (
<p className="loading">Loading servers...</p>
{ loading ? (<p> Loading servers...</p>
) : (
<div className="field">
<label>
Choose a server to deploy to:
<div>
<label>
Choose a server to deploy to:
<select
className="select-input"
value={selectedServer}
onChange={(e) => setSelectedServer(e.target.value)}
name="server"
value={formData.server || ""}
onChange={handleChange}
>
<option value="">None</option>
{servers.map((server) => (
@ -69,58 +72,45 @@ function RecipeForm({ recipe, onClose }) {
</option>
))}
</select>
</label>
</div>
</label>
</div>
)}
<div className="field">
<div>
<label>
Domain:
<input
name="domain"
placeholder="example.com"
value={formData.domain}
onChange={handleChange}
/>
</label>
</div>
<div className="field field-inline">
<label className="checkbox-label">
<div>
<label>
Chaos Mode Enabled:
<input
type="checkbox"
name="chaos"
checked={formData.chaos}
onChange={(e) =>
setFormData({ ...formData, chaos: e.target.checked })
}
onChange={handleChange}
/>
Chaos Mode
</label>
</div>
<label className="checkbox-label">
<div>
<label>
Autogenerate Secrets:
<input
type="checkbox"
name="secrets"
checked={formData.secrets}
onChange={(e) =>
setFormData({ ...formData, secrets: e.target.checked })
}
onChange={handleChange}
/>
Autogenerate Secrets
</label>
</div>
<div className="form-actions">
<button className="action-btn primary" type="submit">
Submit
</button>
<button className="action-btn" type="button" onClick={onClose}>
Cancel
</button>
</div>
<button type="submit">Submit</button>
<button type="button" onClick={onClose}>Cancel</button>
</form>
);
}
export default RecipeForm;
export default RecipeForm;

View File

@ -1,6 +1,6 @@
@use "../../assets/scss/variables" as *;
@use "../../assets/scss/mixins" as *;
@use "../../assets/scss/global" as *;
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
// Extend global page wrapper
.recipes-page {
@ -11,11 +11,10 @@
@extend .page-content;
}
// Recipes grid
// Servers grid
.recipes-grid {
display: grid;
justify-content: stretch;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: $spacing-xl;
margin-bottom: $spacing-xl;
@ -24,18 +23,19 @@
}
}
// Recipe card
// Server card
.recipe-card {
@include card;
@include card-dimensions(320px, 180px);
row-gap: 1em;
display: grid;
grid-template-rows: 1fr 2fr auto 0.9fr;
transition:
transform $transition-base,
box-shadow $transition-base;
transition: transform $transition-base, box-shadow $transition-base;
position: relative;
overflow: hidden;
max-width: 30em;
&:hover {
transform: translateY(-4px);
box-shadow: $shadow-xl;
}
.recipe-header {
display: flex;
@ -79,6 +79,7 @@
color: rgb(255, 255, 255);
font-weight: 700;
background-color: rgb(111, 128, 255);
}
.tag-feature {
font-size: 12px;
@ -87,6 +88,7 @@
color: rgb(255, 255, 255);
font-weight: 700;
background-color: rgb(22, 180, 22);
}
.recipe-stats {
flex-grow: 1;
@ -99,6 +101,7 @@
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
}
@ -107,12 +110,21 @@
gap: $spacing-sm;
.action-btn {
flex: 1;
@include action-btn(
2px,
$radius-md,
$spacing-sm $spacing-md,
$font-size-sm
);
padding: $spacing-sm $spacing-md;
border: 2px solid $border-color;
background: none;
color: $text-primary;
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 {
background-color: $primary;
@ -149,12 +161,12 @@
}
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
justify-content: center;
align-items: center;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
justify-content: center;
align-items: center;
}
.modal {

View File

@ -1,59 +1,61 @@
import React, { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Header } from "../../components/Header/Header";
import { apiService } from "../../services/api";
import type { AbraApp, AbraRecipe, AppWithServer } from "../../types";
import RecipeForm from "./RecipeForm.tsx";
import "./Recipes.scss";
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Header } from '../../components/Header/Header';
import { apiService } from '../../services/api';
import type { AbraApp, AppWithServer, AbraRecipe } from '../../types';
import RecipeForm from './RecipeForm.tsx'
import './Recipes.scss';
export const Recipes: React.FC = () => {
const navigate = useNavigate();
const [recipesData, setRecipesData] = useState<AbraRecipe[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchTerm, setSearchTerm] = useState("");
const [filterServer, setFilterServer] = useState<string>("all");
const [filterStatus, setFilterStatus] = useState<string>("all");
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [filterServer, setFilterServer] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [selectedRecipe, setSelectedRecipe] = useState(null);
const [showForm, setShowForm] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === "true";
const isMockMode = false;
useEffect(() => {
const fetchData = async () => {
try {
if (isMockMode) {
const { mockApiService } = await import("../../services/mockApi");
const { mockApiService } = await import('../../services/mockApi');
const data = await mockApiService.getRecipes();
console.log(data);
console.log(data)
setRecipesData(data);
} else {
const data = await apiService.getRecipes();
setRecipesData(data);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load apps");
setError(err instanceof Error ? err.message : 'Failed to load apps');
} finally {
setLoading(false);
}
};
};
fetchData();
}, [isMockMode]);
// Flatten and enrich apps data
const allRecipes: AbraRecipe[] = useMemo(() => {
if (!recipesData) return [];
return recipesData;
}, [recipesData]);
// Filter recipes
// Filter apps
const filteredRecipes = useMemo(() => {
return allRecipes.filter((recipe) => {
const matchesSearch = recipe.name
.toLowerCase()
.includes(searchTerm.toLowerCase());
return allRecipes.filter(recipe => {
const matchesSearch =
recipe.name.toLowerCase().includes(searchTerm.toLowerCase());
return matchesSearch;
});
@ -70,7 +72,7 @@ export const Recipes: React.FC = () => {
<div className="apps-page">
<Header />
<main className="recipes-content">
<div className="loading">Loading applications...</div>
<div className="loading">Loading recipes...</div>
</main>
</div>
);
@ -92,38 +94,55 @@ export const Recipes: React.FC = () => {
<Header />
<main className="recipes-content">
<div className="page-header">
<h1>Recipes</h1>
<h1>Applications</h1>
<p className="subtitle">{stats.total} recipes</p>
</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 */}
<div className="filters">
<input
type="text"
placeholder="Search recipes by name or description..."
placeholder="Search apps by name, recipe, or domain..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
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>
{/* Server Cards */}
<div className="recipes-grid">
{filteredRecipes.length === 0 ? (
<div className="no-results">
No recipes found matching your search
</div>
<div className="no-results">No recipes found matching your search</div>
) : (
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-title">
<h3>{recipe.name}</h3>
</div>
<div>
{recipe.icon.length > 0 ? (
<img src={`${recipe.icon}`} />
) : null}
{recipe.icon.length > 0 ? <img src={`${recipe.icon}`} /> : null }
</div>
</div>
@ -134,44 +153,32 @@ export const Recipes: React.FC = () => {
</div>
<div className="recipe-actions">
<button
<button
className="action-btn"
onClick={(e) => {
e.stopPropagation();
console.log("clicked");
console.log('clicked');
setSelectedRecipe(recipe);
console.log("selectedRecipe:", selectedRecipe);
console.log('selectedRecipe:', selectedRecipe);
}}
>
Add Recipe
</button>
</div>
<div className="card-tags">
{recipe.features.backups.toLowerCase().includes("yes") ? (
<span className="tag-feature"> Backups </span>
) : null}
{recipe.features.healthcheck.toLowerCase().includes("yes") ? (
<span className="tag-feature"> Healthcheck </span>
) : null}
{recipe.category.length > 0 ? (
<span className="tag-category"> {recipe.category} </span>
) : null}
{recipe.features.backups.toLowerCase().includes("yes") ? <span className="tag-feature"> Backups </span> : null}
{recipe.features.healthcheck.toLowerCase().includes("yes") ? <span className="tag-feature"> Healthcheck </span> : null}
{recipe.category.length > 0 ? <span className="tag-category"> {recipe.category} </span> : null}
</div>
</div>
))
)}
</div>
{selectedRecipe && (
<div
className="modal-overlay"
onClick={() => setSelectedRecipe(null)}
>
<div className="modal-overlay" onClick={() => setSelectedRecipe(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
{}
<RecipeForm
recipe={selectedRecipe}
onClose={() => setSelectedRecipe(null)}
/>
<RecipeForm recipe={selectedRecipe} onClose={() => setSelectedRecipe(null)} />
</div>
</div>
)}
@ -182,4 +189,4 @@ export const Recipes: React.FC = () => {
</main>
</div>
);
};
};

View File

@ -1,7 +1,6 @@
@use "../../assets/scss/variables" as *;
@use "../../assets/scss/mixins" as *;
@use "../../assets/scss/global" as *;
@use "sass:color";
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
.server-detail-page {
@extend .page-wrapper;
@ -48,7 +47,25 @@
// Action buttons (shared with App view, could be moved to global)
.action-btn {
@include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm);
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 {
background: $primary;
@ -67,7 +84,7 @@
border-color: $border-color;
&:hover:not(:disabled) {
background: color.adjust($bg-secondary, $lightness: -10%);
background: darken($bg-secondary, 5%);
}
}
@ -77,7 +94,7 @@
border-color: $error;
&:hover:not(:disabled) {
background: color.adjust($error, $lightness: -10%);
background: darken($error, 10%);
}
}
}
@ -107,7 +124,7 @@
gap: $spacing-xs;
padding: $spacing-xs $spacing-md;
background: rgba($warning, 0.1);
background: color.adjust($error, $lightness: -20%);
color: darken($warning, 20%);
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
@ -172,11 +189,11 @@
color: $text-primary;
&.warning {
background: color.adjust($warning, $lightness: -10%);
color: darken($warning, 10%);
}
&.chaos {
background: color.adjust($info, $lightness: -10%);
color: darken($info, 10%);
}
}
@ -195,7 +212,9 @@
}
.apps-list {
@include card-list-vertical;
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.app-item {

View File

@ -1,11 +1,11 @@
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Header } from "../../components/Header/Header";
import { Terminal } from "../../components/Terminal/Terminal";
import { apiService } from "../../services/api";
import type { AbraServer, ServerAppsResponse } from "../../types";
import type { LogEntry } from "../../services/mockApi";
import "./Server.scss";
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '../../components/Header/Header';
import { Terminal } from '../../components/Terminal/Terminal';
import { apiService } from '../../services/api';
import type { AbraServer, ServerAppsResponse } from '../../types';
import type { LogEntry } from '../../services/mockApi';
import './Server.scss';
interface ServerWithApps extends AbraServer {
apps: any[];
@ -19,29 +19,29 @@ interface ServerWithApps extends AbraServer {
export const Server: React.FC = () => {
const { serverName } = useParams<{ serverName: string }>();
const navigate = useNavigate();
const [server, setServer] = useState<ServerWithApps | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState<string | null>(null);
// Terminal state
const [terminalLogs, setTerminalLogs] = useState<LogEntry[]>([]);
const [terminalActive, setTerminalActive] = useState(false);
const isMockMode = import.meta.env.VITE_MOCK_AUTH === "true";
const isMockMode = false;
useEffect(() => {
const fetchServer = async () => {
try {
if (isMockMode) {
const { mockApiService } = await import("../../services/mockApi");
const { mockApiService } = await import('../../services/mockApi');
const appsData = await mockApiService.getAppsGrouped();
const serversData = await mockApiService.getServers();
const foundServer = serversData.find((s) => s.name === serverName);
const serverApps = appsData[serverName || ""];
const foundServer = serversData.find(s => s.name === serverName);
const serverApps = appsData[serverName || ''];
if (foundServer && serverApps) {
setServer({
...foundServer,
@ -53,15 +53,15 @@ export const Server: React.FC = () => {
latestCount: serverApps.latestCount,
});
} else {
setError("Server not found");
setError('Server not found');
}
} else {
const appsData = await apiService.getAppsGrouped();
const serversData = await apiService.getServers();
const foundServer = serversData.find((s) => s.name === serverName);
const serverApps = appsData[serverName || ""];
const foundServer = serversData.find(s => s.name === serverName);
const serverApps = appsData[serverName || ''];
if (foundServer && serverApps) {
setServer({
...foundServer,
@ -73,11 +73,11 @@ export const Server: React.FC = () => {
latestCount: serverApps.latestCount,
});
} else {
setError("Server not found");
setError('Server not found');
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load server");
setError(err instanceof Error ? err.message : 'Failed to load server');
} finally {
setLoading(false);
}
@ -88,36 +88,28 @@ export const Server: React.FC = () => {
const handleAction = async (action: string) => {
if (!server) return;
setActionLoading(action);
setTerminalActive(true);
setTerminalLogs([]);
try {
if (isMockMode) {
const { mockApiService } = await import("../../services/mockApi");
const { mockApiService } = await import('../../services/mockApi');
const onLog = (log: LogEntry) => {
setTerminalLogs((prev) => [...prev, log]);
setTerminalLogs(prev => [...prev, log]);
};
switch (action) {
case "refresh":
case 'refresh':
await mockApiService.refreshServer(server.name, onLog);
break;
case "deploy-all":
await mockApiService.deployAllApps(
server.name,
server.appCount,
onLog,
);
case 'deploy-all':
await mockApiService.deployAllApps(server.name, server.appCount, onLog);
break;
case "upgrade-all":
await mockApiService.upgradeAllApps(
server.name,
server.upgradeCount,
onLog,
);
case 'upgrade-all':
await mockApiService.upgradeAllApps(server.name, server.upgradeCount, onLog);
break;
}
} else {
@ -125,15 +117,12 @@ export const Server: React.FC = () => {
console.log(`Action: ${action} on server ${server.name}`);
}
} catch (err) {
console.error("Action failed:", err);
setTerminalLogs((prev) => [
...prev,
{
type: "error",
text: `❌ Error: ${err instanceof Error ? err.message : "Action failed"}`,
timestamp: new Date(),
},
]);
console.error('Action failed:', err);
setTerminalLogs(prev => [...prev, {
type: 'error',
text: `❌ Error: ${err instanceof Error ? err.message : 'Action failed'}`,
timestamp: new Date()
}]);
} finally {
setActionLoading(null);
}
@ -155,8 +144,8 @@ export const Server: React.FC = () => {
<div className="server-detail-page">
<Header />
<main className="server-detail-content">
<div className="error">{error || "Server not found"}</div>
<button onClick={() => navigate("/servers")} className="back-button">
<div className="error">{error || 'Server not found'}</div>
<button onClick={() => navigate('/servers')} className="back-button">
Back to Servers
</button>
</main>
@ -164,20 +153,15 @@ export const Server: React.FC = () => {
);
}
const chaosApps = server.apps.filter((app) => app.chaos === "true");
const runningApps = server.apps.filter(
(app) => app.status === "deployed" || app.status === "running",
);
const chaosApps = server.apps.filter(app => app.chaos === 'true');
const runningApps = server.apps.filter(app => app.status === 'deployed' || app.status === 'running');
return (
<div className="server-detail-page">
<Header />
<main className="server-detail-content">
<div className="breadcrumb">
<button
onClick={() => navigate("/servers")}
className="breadcrumb-link"
>
<button onClick={() => navigate('/servers')} className="breadcrumb-link">
Servers
</button>
<span className="breadcrumb-separator">/</span>
@ -191,34 +175,33 @@ export const Server: React.FC = () => {
<span className="host-badge">{server.host}</span>
{server.upgradeCount > 0 && (
<span className="upgrade-badge">
{server.upgradeCount} upgrade
{server.upgradeCount !== 1 ? "s" : ""}
{server.upgradeCount} upgrade{server.upgradeCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
<div className="server-actions">
<button
<button
className="action-btn secondary"
onClick={() => handleAction("refresh")}
onClick={() => handleAction('refresh')}
disabled={!!actionLoading}
>
{actionLoading === "refresh" ? "Refreshing..." : "Refresh"}
{actionLoading === 'refresh' ? 'Refreshing...' : '🔄 Refresh'}
</button>
<button
className="action-btn"
onClick={() => handleAction("deploy-all")}
<button
className="action-btn primary"
onClick={() => handleAction('deploy-all')}
disabled={!!actionLoading}
>
{actionLoading === "deploy-all" ? "Deploying..." : "Deploy All"}
{actionLoading === 'deploy-all' ? 'Deploying...' : '🚀 Deploy All'}
</button>
</div>
</div>
{/* Terminal Component */}
<Terminal
logs={terminalLogs}
<Terminal
logs={terminalLogs}
isActive={terminalActive}
onClose={() => setTerminalActive(false)}
/>
@ -228,7 +211,7 @@ export const Server: React.FC = () => {
<div className="main-column">
<section className="info-card">
<h2>Server Details</h2>
<div className="info-grid">
<div className="info-item">
<label>Server Name</label>
@ -252,24 +235,14 @@ export const Server: React.FC = () => {
<div className="info-item">
<label>Upgrades Available</label>
<span
className={
server.upgradeCount > 0
? "stat-value warning"
: "stat-value"
}
>
<span className={server.upgradeCount > 0 ? 'stat-value warning' : 'stat-value'}>
{server.upgradeCount}
</span>
</div>
<div className="info-item">
<label>Chaos Mode Apps</label>
<span
className={
chaosApps.length > 0 ? "stat-value chaos" : "stat-value"
}
>
<span className={chaosApps.length > 0 ? 'stat-value chaos' : 'stat-value'}>
{chaosApps.length}
</span>
</div>
@ -288,20 +261,16 @@ export const Server: React.FC = () => {
<section className="info-card">
<h2>Applications on this Server</h2>
{server.apps.length === 0 ? (
<div className="no-apps">
No applications deployed on this server
</div>
<div className="no-apps">No applications deployed on this server</div>
) : (
<div className="apps-list">
{server.apps.map((app, idx) => (
<div
<div
key={`${app.appName}-${idx}`}
className="app-item"
onClick={() =>
navigate(`/apps/${server.name}/${app.appName}`)
}
onClick={() => navigate(`/apps/${server.name}/${app.appName}`)}
>
<div className="app-item-header">
<span className="app-item-name">{app.appName}</span>
@ -310,10 +279,10 @@ export const Server: React.FC = () => {
<span className={`status-badge status-${app.status}`}>
{app.status}
</span>
{app.chaos === "true" && (
<span className="chaos-badge"></span>
{app.chaos === 'true' && (
<span className="chaos-badge">🔬</span>
)}
{app.upgrade !== "latest" && (
{app.upgrade !== 'latest' && (
<span className="upgrade-badge"></span>
)}
</div>
@ -321,7 +290,7 @@ export const Server: React.FC = () => {
<div className="app-item-details">
<span className="app-item-version">v{app.version}</span>
{app.domain && (
<a
<a
href={`https://${app.domain}`}
target="_blank"
rel="noopener noreferrer"
@ -343,30 +312,30 @@ export const Server: React.FC = () => {
<div className="sidebar-column">
<section className="info-card">
<h2>Quick Actions</h2>
<div className="action-list">
<button
<button
className="action-list-item"
onClick={() => handleAction("refresh")}
onClick={() => handleAction('refresh')}
disabled={!!actionLoading}
>
<span className="action-text">Refresh Server Info</span>
</button>
<button
<button
className="action-list-item"
onClick={() => handleAction("deploy-all")}
onClick={() => handleAction('deploy-all')}
disabled={!!actionLoading}
>
<span className="action-text">Deploy All Apps</span>
</button>
<button
<button
className="action-list-item"
onClick={() => handleAction("upgrade-all")}
onClick={() => handleAction('upgrade-all')}
disabled={!!actionLoading || server.upgradeCount === 0}
>
<span className="action-text">Upgrade All Apps</span>
<span className="action-text">Upgrade All Apps</span>
</button>
</div>
</section>

View File

@ -1,6 +1,6 @@
@use "../../assets/scss/variables" as *;
@use "../../assets/scss/mixins" as *;
@use "../../assets/scss/global" as *;
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@use '../../assets/scss/global' as *;
// Extend global page wrapper
.servers-page {
@ -14,12 +14,10 @@
// Servers grid
.servers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: $spacing-xl;
margin-bottom: $spacing-xl;
align-items: stretch;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
@ -28,24 +26,44 @@
// Server card
.server-card {
@include card;
@include card-hover-lift(-4px, $shadow-xl);
@include card-dimensions(320px, 180px);
display: flex;
flex-direction: column;
transition: transform $transition-base, box-shadow $transition-base;
position: relative;
overflow: hidden;
&:hover {
transform: translateY(-4px);
box-shadow: $shadow-xl;
}
.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 {
@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 {
.status-indicator {
font-size: $font-size-xl;
&.connected {
color: $success;
}
@ -62,12 +80,24 @@
margin-bottom: $spacing-lg;
.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
&.highlight {
@include card-row-highlight-bleed;
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 {
font-weight: $font-weight-semibold;
@ -79,8 +109,11 @@
}
&.chaos-row {
@include card-row-highlight-bleed(false);
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 {
font-weight: $font-weight-semibold;
@ -119,17 +152,26 @@
.action-btn {
flex: 1;
@include action-btn(
2px,
$radius-md,
$spacing-sm $spacing-md,
$font-size-sm
);
padding: $spacing-sm $spacing-md;
border: 2px solid $border-color;
background: none;
color: $text-primary;
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 {
// background-color: $primary;
// color: white;
// border-color: $primary;
background-color: $primary;
color: white;
border-color: $primary;
&:hover {
background-color: $primary-light;

View File

@ -1,9 +1,9 @@
import React, { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Header } from "../../components/Header/Header";
import { apiService } from "../../services/api";
import type { AbraServer, ServerAppsResponse } from "../../types";
import "./Servers.scss";
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Header } from '../../components/Header/Header';
import { apiService } from '../../services/api';
import type { AbraServer, ServerAppsResponse } from '../../types';
import './Servers.scss';
interface ServerWithStats extends AbraServer {
appCount: number;
@ -17,52 +17,63 @@ export const Servers: React.FC = () => {
const navigate = useNavigate();
const [servers, setServers] = useState<ServerWithStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchTerm, setSearchTerm] = useState("");
const [sortBy, setSortBy] = useState<"name" | "apps" | "upgrades">("name");
const [showUpgradesOnly, setShowUpgradesOnly] = useState(false);
const [showChaosOnly, setShowChaosOnly] = useState(false);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'name' | 'apps' | 'upgrades'>('name');
const isMockMode = import.meta.env.VITE_MOCK_AUTH === "true";
const isMockMode = false;
useEffect(() => {
const fetchData = async () => {
try {
let serversData, appsData;
if (isMockMode) {
const { mockApiService } = await import("../../services/mockApi");
[serversData, appsData] = await Promise.all([
const { mockApiService } = await import('../../services/mockApi');
const [serversData, appsData] = await Promise.all([
mockApiService.getServers(),
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 {
[serversData, appsData] = await Promise.all([
const [serversData, appsData] = await Promise.all([
apiService.getServers(),
apiService.getAppsGrouped(),
]);
// Enrich servers with stats from apps data
const enrichedServers = serversData.map(server => {
const serverStats = appsData[server.name];
const chaosCount = serverStats?.apps.filter(app => app.chaos === 'true').length || 0;
return {
...server,
appCount: serverStats?.appCount || 0,
versionCount: serverStats?.versionCount || 0,
latestCount: serverStats?.latestCount || 0,
upgradeCount: serverStats?.upgradeCount || 0,
chaosCount,
};
});
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) {
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 {
setLoading(false);
}
@ -81,29 +92,28 @@ export const Servers: React.FC = () => {
return { totalServers, totalApps, totalUpgrades, totalChaos };
}, [servers]);
// Filter and sort servers (additive filters allowed)
// Filter and sort servers
const filteredServers = useMemo(() => {
const filtered = servers.filter((server) => {
const matchesSearch =
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
server.host.toLowerCase().includes(searchTerm.toLowerCase());
const matchesUpgrades = !showUpgradesOnly || server.upgradeCount > 0;
const matchesChaos = !showChaosOnly || server.chaosCount > 0;
return matchesSearch && matchesUpgrades && matchesChaos;
});
const filtered = servers.filter(server =>
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
server.host.toLowerCase().includes(searchTerm.toLowerCase())
);
// Sort
filtered.sort((a, b) => {
switch (sortBy) {
case "apps":
case 'apps':
return b.appCount - a.appCount;
case "name":
case 'upgrades':
return b.upgradeCount - a.upgradeCount;
case 'name':
default:
return a.name.localeCompare(b.name);
}
});
return filtered;
}, [servers, searchTerm, sortBy, showUpgradesOnly]);
}, [servers, searchTerm, sortBy]);
if (loading) {
return (
@ -133,42 +143,39 @@ export const Servers: React.FC = () => {
<main className="servers-content">
<div className="page-header">
<h1>Servers</h1>
<p className="subtitle">
Managing {stats.totalServers} servers with {stats.totalApps}{" "}
applications
</p>
<p className="subtitle">Managing {stats.totalServers} servers with {stats.totalApps} applications</p>
</div>
{/* Compact Stats Row */}
<div className="stats-row">
<button
className="stat-chip"
onClick={() => navigate("/apps")}
title="View all apps"
>
<span className="stat-label">Apps</span>
<span className="stat-value">{stats.totalApps}</span>
</button>
<button
className={`stat-chip filter-chip ${showUpgradesOnly ? "active" : ""}`}
onClick={() => setShowUpgradesOnly((prev) => !prev)}
title="Click to filter by servers with upgrades"
disabled={stats.totalUpgrades === 0}
>
<span className="stat-label">Upgrades</span>
<span className="stat-value">{stats.totalUpgrades}</span>
</button>
<button
className={`stat-chip filter-chip ${showChaosOnly ? "active" : ""}`}
onClick={() => setShowChaosOnly((prev) => !prev)}
title="Click to filter servers with chaos apps"
disabled={stats.totalChaos === 0}
>
<span className="stat-label">Chaos</span>
<span className="stat-value">{stats.totalChaos}</span>
</button>
{/* Stats Overview */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-info">
<p className="stat-number">{stats.totalServers}</p>
<p className="stat-label">Total Servers</p>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-info">
<p className="stat-number">{stats.totalApps}</p>
<p className="stat-label">Total Apps</p>
</div>
</div>
<div className="stat-card upgrade">
<div className="stat-icon"></div>
<div className="stat-info">
<p className="stat-number">{stats.totalUpgrades}</p>
<p className="stat-label">Apps Need Upgrade</p>
</div>
</div>
<div className="stat-card chaos">
<div className="stat-icon"></div>
<div className="stat-info">
<p className="stat-number">{stats.totalChaos}</p>
<p className="stat-label">Chaos Apps</p>
</div>
</div>
</div>
{/* Filters */}
@ -180,21 +187,25 @@ export const Servers: React.FC = () => {
onChange={(e) => setSearchTerm(e.target.value)}
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>
{/* Server Cards */}
<div className="servers-grid">
{filteredServers.length === 0 ? (
<div className="no-results">
No servers found matching your search
</div>
<div className="no-results">No servers found matching your search</div>
) : (
filteredServers.map((server) => (
<div
key={server.name}
<div
key={server.name}
className="server-card"
onClick={() => navigate(`/servers/${server.name}`)}
style={{ cursor: "pointer" }}
style={{ cursor: 'pointer' }}
>
<div className="server-header">
<div className="server-title">
@ -202,12 +213,7 @@ export const Servers: React.FC = () => {
<span className="server-host">{server.host}</span>
</div>
<div className="server-status">
<span
className="status-indicator connected"
title="Connected"
>
</span>
<span className="status-indicator connected" title="Connected"></span>
</div>
</div>
@ -238,11 +244,32 @@ export const Servers: React.FC = () => {
)}
</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 && (
<div className="server-alert">
<span className="alert-icon"></span>
<span className="alert-text">
{server.upgradeCount} app
{server.upgradeCount > 1 ? "s" : ""} can be upgraded
{server.upgradeCount} app{server.upgradeCount > 1 ? 's' : ''} can be upgraded
</span>
</div>
)}
@ -257,4 +284,4 @@ export const Servers: React.FC = () => {
</main>
</div>
);
};
};

View File

@ -1,18 +1,17 @@
import type { AbraRecipe, AbraServer, ServerAppsResponse } from "../types";
import type { AbraServer, AbraRecipe, ServerAppsResponse, AbraAppService, DeployEvent } from '../types';
// Log entry type
export type LogEntry = {
type: "info" | "success" | "error" | "warning" | "command" | "output";
type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output';
text: string;
timestamp: Date;
};
const API_BASE_URL =
import.meta.env.VITE_API_URL || "http://localhost:3000/api";
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
// Helper to process log JSON from API response
const processLogResponse = (logData: any[]): LogEntry[] => {
return logData.map((log) => ({
return logData.map(log => ({
type: log.type,
text: log.text,
timestamp: new Date(log.timestamp || Date.now()),
@ -22,10 +21,10 @@ const processLogResponse = (logData: any[]): LogEntry[] => {
class ApiService {
private async request<T>(
endpoint: string,
options: RequestInit = {},
options: RequestInit = {}
): Promise<T> {
const headers: HeadersInit = {
"Content-Type": "application/json",
'Content-Type': 'application/json',
...options.headers,
};
@ -35,153 +34,164 @@ class ApiService {
});
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ message: "An error occurred" }));
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
if (response.status === 204) {
return undefined as T;
}
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
async getAppsGrouped(): Promise<ServerAppsResponse> {
return this.request<ServerAppsResponse>("/apps");
return this.request<ServerAppsResponse>('/apps');
}
// Get all servers
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)
async deployApp(
appName: string,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const response = await this.request<{ logs: any[] }>(
`/apps/${appName}/deploy`,
{
method: "POST",
},
);
async deployApp(appName: string): Promise<void> {
return this.request<void>(`/apps/${appName}/deploy`, {
method: 'POST',
});
}
deployLogs(appName: string, msgHandler: (data: DeployEvent) => void) {
return this.stream(`/apps/${appName}/deploy`, {parser: JSON.parse, onMessage: msgHandler})
}
async stopApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/stop`, {
method: 'POST',
});
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach((log) => onLog(log));
logs.forEach(log => onLog(log));
}
}
async undeployApp(
appName: string,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const response = await this.request<{ logs: any[] }>(
`/apps/${appName}/undeploy`,
{
method: "POST",
},
);
async upgradeApp(appName: string, version: string, 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) {
const logs = processLogResponse(response.logs);
logs.forEach((log) => onLog(log));
logs.forEach(log => onLog(log));
}
}
async upgradeApp(
appName: string,
version: string,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const response = await this.request<{ logs: any[] }>(
`/apps/${appName}/upgrade`,
{
method: "POST",
body: JSON.stringify({ version }),
},
);
async removeApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/apps/${appName}/remove`, {
method: 'POST',
});
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach((log) => onLog(log));
logs.forEach(log => onLog(log));
}
}
async removeApp(
appName: string,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const response = await this.request<{ logs: any[] }>(
`/apps/${appName}/remove`,
{
method: "POST",
async newApp(appName: string, formData: Object): Promise<void> {
const response = await this.request<void>(`/apps/${appName}/new`, {
method: 'POST',
headers: {
'Accept': 'application/json'
},
);
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach((log) => onLog(log));
}
body: JSON.stringify(formData)
});
return response
}
// Server actions with log streaming
async refreshServer(
serverName: string,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const response = await this.request<{ logs: any[] }>(
`/servers/${serverName}/refresh`,
{
method: "POST",
},
);
async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/refresh`, {
method: 'POST',
});
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach((log) => onLog(log));
logs.forEach(log => onLog(log));
}
}
async deployAllApps(
serverName: string,
appCount: number,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const response = await this.request<{ logs: any[] }>(
`/servers/${serverName}/deploy-all`,
{
method: "POST",
},
);
async deployAllApps(serverName: string, appCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/deploy-all`, {
method: 'POST',
});
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach((log) => onLog(log));
logs.forEach(log => onLog(log));
}
}
async upgradeAllApps(
serverName: string,
upgradeCount: number,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const response = await this.request<{ logs: any[] }>(
`/servers/${serverName}/upgrade-all`,
{
method: "POST",
},
);
async upgradeAllApps(serverName: string, upgradeCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/upgrade-all`, {
method: 'POST',
});
if (onLog && response.logs) {
const logs = processLogResponse(response.logs);
logs.forEach((log) => onLog(log));
logs.forEach(log => onLog(log));
}
}
// recipe catalog imports
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();

View File

@ -590,4 +590,4 @@
"latestCount": 3,
"upgradeCount": 6
}
}
}

View File

@ -1723,4 +1723,4 @@
],
"website": ""
}
]
]

View File

@ -119,7 +119,7 @@
"logs": [
{
"type": "info",
"text": "Upgrading {appName} to {version}..."
"text": "⬆️ Upgrading {appName} to {version}..."
},
{
"type": "command",
@ -287,7 +287,7 @@
"logs": [
{
"type": "info",
"text": "Upgrading all apps on {serverName}..."
"text": "⬆️ Upgrading all apps on {serverName}..."
},
{
"type": "command",
@ -323,4 +323,4 @@
}
]
}
}
}

View File

@ -27,4 +27,4 @@
"host": "orgsite.org",
"name": "orgsite.org"
}
]
]

View File

@ -1,19 +1,20 @@
import type { AbraRecipe, AbraServer, ServerAppsResponse } from "../types";
import appsData from "./mock-apps.json";
import serversData from "./mock-servers.json";
import logsData from "./mock-logs.json";
import catalogue from "./mock-catalogue.json";
import type { AbraServer, AbraRecipe, ServerAppsResponse } from '../types';
import appsData from './mock-apps.json';
import serversData from './mock-servers.json';
import logsData from './mock-logs.json';
import catalogue from './mock-catalogue.json';
// Log entry type
export type LogEntry = {
type: "info" | "success" | "error" | "warning" | "command" | "output";
type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output';
text: string;
timestamp: Date;
};
// Type for the imported JSON structure
type LogTemplate = {
type: "info" | "success" | "error" | "warning" | "command" | "output";
type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output';
text: string;
};
@ -24,16 +25,13 @@ type LogsDataStructure = {
};
// 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}
const replaceVars = (
text: string,
vars: Record<string, string | number>,
): string => {
const replaceVars = (text: string, vars: Record<string, string | number>): string => {
let result = text;
Object.entries(vars).forEach(([key, value]) => {
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value));
});
return result;
};
@ -41,25 +39,25 @@ const replaceVars = (
// Helper to process log templates into LogEntry objects
const processLogs = (
action: string,
vars: Record<string, string | number>,
vars: Record<string, string | number>
): LogEntry[] => {
console.log("Processing logs for action:", action);
console.log("Loaded logsData:", logsData);
console.log('Processing logs for action:', action);
console.log('Loaded logsData:', logsData);
const typedLogsData = logsData as LogsDataStructure;
const actionData = typedLogsData[action];
if (!actionData || !actionData.logs) {
console.error(`No logs found for action: ${action}`);
return [];
}
console.log("Found logs:", actionData.logs);
console.log('Found logs:', actionData.logs);
const templates = actionData.logs;
return templates
.filter((template) => template && template.type && template.text) // Filter out any undefined/malformed entries
.map((template) => ({
.filter(template => template && template.type && template.text) // Filter out any undefined/malformed entries
.map(template => ({
type: template.type,
text: replaceVars(template.text, vars),
timestamp: new Date(),
@ -77,87 +75,63 @@ export const mockApiService = {
return serversData as AbraServer[];
},
async deployApp(
appName: string,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const logs = processLogs("deploy", { appName });
async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('deploy', { appName });
for (const log of logs) {
await delay(200);
onLog?.(log);
}
},
async stopApp(
appName: string,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const logs = processLogs("stop", { appName });
async stopApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('stop', { appName });
for (const log of logs) {
await delay(150);
onLog?.(log);
}
},
async upgradeApp(
appName: string,
version: string,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const logs = processLogs("upgrade", { appName, version });
async upgradeApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('upgrade', { appName, version });
for (const log of logs) {
await delay(200);
onLog?.(log);
}
},
async removeApp(
appName: string,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const logs = processLogs("remove", { appName });
async removeApp(appName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('remove', { appName });
for (const log of logs) {
await delay(180);
onLog?.(log);
}
},
async refreshServer(
serverName: string,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const logs = processLogs("serverRefresh", { serverName });
async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('serverRefresh', { serverName });
for (const log of logs) {
await delay(200);
onLog?.(log);
}
},
async deployAllApps(
serverName: string,
appCount: number,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const logs = processLogs("deployAll", { serverName, appCount });
async deployAllApps(serverName: string, appCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('deployAll', { serverName, appCount });
for (const log of logs) {
await delay(250);
onLog?.(log);
}
},
async upgradeAllApps(
serverName: string,
upgradeCount: number,
onLog?: (log: LogEntry) => void,
): Promise<void> {
const logs = processLogs("upgradeAll", { serverName, upgradeCount });
async upgradeAllApps(serverName: string, upgradeCount: number, onLog?: (log: LogEntry) => void): Promise<void> {
const logs = processLogs('upgradeAll', { serverName, upgradeCount });
for (const log of logs) {
await delay(250);
onLog?.(log);
@ -167,5 +141,5 @@ export const mockApiService = {
async getRecipes(): Promise<AbraRecipe[]> {
await delay(300);
return catalogue as AbraRecipe[];
},
};
}
};

View File

@ -8,7 +8,7 @@ export interface AbraApp {
recipe: string;
appName: string;
domain: string;
status: "deployed" | "stopped" | "unknown";
status: 'deployed' | 'stopped' | 'unknown';
chaos: string;
chaosVersion: string;
version: string;
@ -37,16 +37,53 @@ export interface AppWithServer extends AbraApp {
upgradeCount: number;
};
}
export interface AbraAppService {
service: string;
chaos: boolean;
created: string;
image: string;
ports: string;
state: string;
status: string;
version: string;
}
export interface AbraServiceState {
name: string;
err: string;
id: string;
status: string;
retries: number;
health: string;
rollback: boolean;
failed: boolean;
}
export interface DeployState {
count: number;
total: number;
failed: boolean;
quit: boolean;
}
export type DeployEvent =
| {
type: "service";
data: AbraServiceState;
}
| {
type: "done";
data: DeployState;
}
export interface Image {
image: string;
rating: string;
source: string;
url: string;
rating: string
source: string
url: string
}
type RecipeVersions = Record<string, Record<string, ServiceMeta>>[];
export interface ServiceMeta {
image: string;
tag: string;
tag: string
}
export interface Features {
backups: string;