diff --git a/.env b/.env index ff9c79c..a53bee2 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ VITE_API_URL=http://localhost:3000/api +VITE_WS_URL=ws://localhost:3000 VITE_MOCK_AUTH=true \ No newline at end of file diff --git a/.env.example b/.env.example index b87d9f0..f21daf3 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # API base URL for the backend that wraps the abra CLI VITE_API_URL=http://localhost:3000/api -# Set to 'true' to bypass authentication for development +# Set to 'true' to use mock data VITE_MOCK_AUTH=true \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 30eaaee..6d37791 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,37 +1,28 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; -import { AuthProvider } from './context/AuthContext'; -import { LoginForm } from './routes/Login/LoginForm'; -import { Authenticated } from './components/Authenticated'; -import { Dashboard } from './routes/Authenticated/Dashboard/Dashboard'; -import { Apps } from './routes/Authenticated/Apps/Apps'; -import { AppDetail } from './routes/Authenticated/Apps/App'; -import { Servers } from './routes/Authenticated/Servers/Servers'; -import { Server } from './routes/Authenticated/Servers/Server'; +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 ( - - - {/* Public routes */} - } /> - - {/* Protected routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - - + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* 404 catch-all */} } /> - ); } -export default App; \ No newline at end of file +export default App; diff --git a/public/coopcloud_logo_grey.svg b/src/assets/coopcloud_logo_grey.svg similarity index 100% rename from public/coopcloud_logo_grey.svg rename to src/assets/coopcloud_logo_grey.svg diff --git a/src/assets/scss/_global.scss b/src/assets/scss/_global.scss index 9fbde7a..612f936 100644 --- a/src/assets/scss/_global.scss +++ b/src/assets/scss/_global.scss @@ -1,6 +1,7 @@ @use './variables' as *; @use './mixins' as *; + body { margin: 0; padding: 0; @@ -85,6 +86,7 @@ body { // Modifier classes for colored borders &.upgrade { border-left: 4px solid $primary-light; + cursor: none; } &.chaos { @@ -234,6 +236,18 @@ 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; diff --git a/src/assets/scss/_mixins.scss b/src/assets/scss/_mixins.scss index ab73615..2653d6a 100644 --- a/src/assets/scss/_mixins.scss +++ b/src/assets/scss/_mixins.scss @@ -23,11 +23,98 @@ // 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; @@ -63,4 +150,63 @@ 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; + } } \ No newline at end of file diff --git a/src/components/Authenticated.tsx b/src/components/Authenticated.tsx deleted file mode 100644 index a66b1f1..0000000 --- a/src/components/Authenticated.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { Navigate, Outlet } from 'react-router-dom'; -import { useAuth } from '../context/AuthContext'; - -export const Authenticated: React.FC = () => { - const { isAuthenticated, loading } = useAuth(); - - console.log('🛡️ ProtectedRoute:', { isAuthenticated, loading }); - - if (loading) { - return ( -
-

Loading...

-
- ); - } - - return isAuthenticated ? : ; -}; \ No newline at end of file diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index ac9240c..3cc8ff2 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,44 +1,32 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../hooks/useAuth'; import './_Header.scss'; +import logo from '../../assets/coopcloud_logo_grey.svg'; + interface HeaderProps { children: React.ReactNode; } export const Header: React.FC = ({ children }) => { - const { user, logout } = useAuth(); const navigate = useNavigate(); - - const handleLogout = async () => { - await logout(); - navigate('/login'); - }; return(

navigate('/dashboard')} className="logo"> - +

- -
- {user?.username} - -
+
)} \ No newline at end of file diff --git a/src/components/Header/_Header.scss b/src/components/Header/_Header.scss index 9b8f02b..480d9ec 100644 --- a/src/components/Header/_Header.scss +++ b/src/components/Header/_Header.scss @@ -27,12 +27,14 @@ 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%; @@ -78,50 +80,4 @@ font-size: $font-size-xl; } } - - .user-menu { - display: flex; - align-items: center; - gap: $spacing-lg; - - @media (max-width: 768px) { - width: 100%; - justify-content: space-between; - } - - .username { - font-weight: $font-weight-medium; - font-size: $font-size-base; - - @media (max-width: 480px) { - font-size: $font-size-sm; - } - } - - .logout-button { - background: rgba(255, 255, 255, 0.2); - border: 1px solid rgba(255, 255, 255, 0.4); - color: $text-primary; - padding: $spacing-sm $spacing-lg; - border-radius: $radius-md; - cursor: pointer; - transition: all $transition-base; - font-weight: $font-weight-medium; - - &:hover { - background: rgba(255, 255, 255, 0.3); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - } - - &:active { - transform: translateY(0); - } - - @media (max-width: 480px) { - padding: $spacing-sm $spacing-md; - font-size: $font-size-sm; - } - } - } } \ No newline at end of file diff --git a/src/components/Terminal/Terminal.tsx b/src/components/Terminal/Terminal.tsx new file mode 100644 index 0000000..bd878c7 --- /dev/null +++ b/src/components/Terminal/Terminal.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useRef, useState } from 'react'; +import type { LogEntry } from '../../services/mockApi'; // Import from mockApi (or api for real) +import './_Terminal.scss'; + +interface TerminalProps { + logs: LogEntry[]; + isActive: boolean; + onClose?: () => void; +} + +export const Terminal: React.FC = ({ logs, isActive, onClose }) => { + const terminalRef = useRef(null); + const [displayedLogs, setDisplayedLogs] = useState([]); + + // Stream logs in with delays for realistic effect + useEffect(() => { + if (!isActive || logs.length === 0) { + setDisplayedLogs([]); + return; + } + + setDisplayedLogs([]); + let currentIndex = 0; + + const streamLogs = () => { + if (currentIndex < logs.length) { + setDisplayedLogs(prev => [...prev, logs[currentIndex]]); + currentIndex++; + + // Variable delay based on log type + const delay = logs[currentIndex - 1]?.type === 'command' ? 200 : + logs[currentIndex - 1]?.type === 'output' ? 100 : 300; + + setTimeout(streamLogs, delay); + } + }; + + streamLogs(); + }, [logs, isActive]); + + // Auto-scroll to bottom + useEffect(() => { + if (terminalRef.current) { + terminalRef.current.scrollTop = terminalRef.current.scrollHeight; + } + }, [displayedLogs]); + + if (!isActive && displayedLogs.length === 0) { + return null; + } + + const formatTime = (date: Date) => { + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + }; + + return ( +
+
+
+ + + +
+
abra CLI
+
+
+ +
+ {displayedLogs.filter(log => log && log.type).map((log, index) => ( +
+ [{formatTime(log.timestamp)}] + {log.text} +
+ ))} + {isActive && displayedLogs.length > 0 && ( +
+ )} +
+
+ ); +}; diff --git a/src/components/Terminal/_Terminal.scss b/src/components/Terminal/_Terminal.scss new file mode 100644 index 0000000..fc1579e --- /dev/null +++ b/src/components/Terminal/_Terminal.scss @@ -0,0 +1,204 @@ +@use '../../assets/scss/variables' as *; +@use '../../assets/scss/mixins' as *; + +.terminal-container { + background: #1e1e1e; + border-radius: $radius-md; + overflow: hidden; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; + margin-bottom: $spacing-xl; + animation: terminal-appear 0.3s ease-out; +} + +@keyframes terminal-appear { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.terminal-header { + display: flex; + align-items: center; + padding: $spacing-sm $spacing-md; + background: #323232; + border-bottom: 1px solid #404040; + user-select: none; + + .terminal-controls { + display: flex; + gap: $spacing-xs; + min-width: 60px; + } + + .terminal-control { + width: 12px; + height: 12px; + border-radius: 50%; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.8; + } + + &.close { + background: #ff5f56; + } + + &.minimize { + background: #ffbd2e; + } + + &.maximize { + background: #27c93f; + } + } + + .terminal-title { + flex: 1; + text-align: center; + color: #a0a0a0; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + } + + .terminal-spacer { + min-width: 60px; + } +} + +.terminal-content { + padding: $spacing-md; + background: #1e1e1e; + color: #d4d4d4; + font-size: 13px; + line-height: 1.6; + max-height: 400px; + overflow-y: auto; + overflow-x: hidden; + + /* Custom scrollbar */ + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: #2d2d2d; + } + + &::-webkit-scrollbar-thumb { + background: #4a4a4a; + border-radius: 4px; + + &:hover { + background: #5a5a5a; + } + } +} + +.terminal-line { + display: flex; + margin-bottom: 2px; + animation: terminal-line-appear 0.2s ease-out; + white-space: pre-wrap; + word-break: break-word; + + @keyframes terminal-line-appear { + from { + opacity: 0; + transform: translateX(-4px); + } + to { + opacity: 1; + transform: translateX(0); + } + } + + .terminal-timestamp { + color: #6a6a6a; + margin-right: $spacing-sm; + flex-shrink: 0; + font-size: 11px; + } + + .terminal-text { + flex: 1; + } + + /* Line type styles */ + &.terminal-info { + .terminal-text { + color: #4fc3f7; + } + } + + &.terminal-success { + .terminal-text { + color: #81c784; + font-weight: $font-weight-medium; + } + } + + &.terminal-error { + .terminal-text { + color: #e57373; + font-weight: $font-weight-medium; + } + } + + &.terminal-warning { + .terminal-text { + color: #ffb74d; + } + } + + &.terminal-command { + .terminal-text { + color: #ba68c8; + font-weight: $font-weight-semibold; + } + } + + &.terminal-output { + .terminal-text { + color: #d4d4d4; + opacity: 0.9; + } + } +} + +.terminal-cursor { + display: inline-block; + color: #81c784; + margin-left: 4px; + animation: terminal-cursor-blink 1s step-end infinite; +} + +@keyframes terminal-cursor-blink { + 0%, 50% { + opacity: 1; + } + 51%, 100% { + opacity: 0; + } +} + +/* Responsive */ +@media (max-width: 768px) { + .terminal-content { + font-size: 12px; + padding: $spacing-sm; + } + + .terminal-line { + .terminal-timestamp { + display: none; + } + } +} \ No newline at end of file diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx deleted file mode 100644 index 3b85f00..0000000 --- a/src/context/AuthContext.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react'; -import { apiService } from '../services/api'; -import type { LoginCredentials, User } from '../types'; - -interface AuthContextType { - user: User | null; - loading: boolean; - login: (credentials: LoginCredentials) => Promise; - logout: () => Promise; - isAuthenticated: boolean; -} - -export const AuthContext = createContext(undefined); - -interface AuthProviderProps { - children: ReactNode; -} - -export const AuthProvider: React.FC = ({ children }) => { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; - - useEffect(() => { - const checkAuth = async () => { - console.log('🔍 checkAuth running, isMockMode:', isMockMode); - try { - if (isMockMode) { - console.log('✅ Mock mode - setting mock user'); - setUser({ - id: 'mock-user-1', - username: 'developer', - email: 'dev@coopcloud.tech', - }); - setLoading(false); - console.log('✅ Mock user set, loading set to false'); - return; - } - - const token = localStorage.getItem('auth_token'); - if (token) { - const currentUser = await apiService.getCurrentUser(); - setUser(currentUser); - } - } catch (error) { - console.error('Auth check failed:', error); - localStorage.removeItem('auth_token'); - } finally { - setLoading(false); - } - }; - - checkAuth(); - }, [isMockMode]); - - const login = async (credentials: LoginCredentials) => { - try { - const response = await apiService.login(credentials); - setUser(response.user); - } catch (error) { - throw error; - } - }; - - const logout = async () => { - await apiService.logout(); - setUser(null); - }; - - const value: AuthContextType = { - user, - loading, - login, - logout, - isAuthenticated: !!user, - }; - console.log('📊 AuthContext state:', { user, loading, isAuthenticated: !!user }); - - return {children}; -}; - -export function useAuth() { - const context = useContext(AuthContext); - if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; -} \ No newline at end of file diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts deleted file mode 100644 index 4c697fa..0000000 --- a/src/hooks/useAuth.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useContext } from 'react'; -import { AuthContext } from '../context/AuthContext'; - -export const useAuth = () => { - const context = useContext(AuthContext); - - if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider'); - } - - return context; -}; \ No newline at end of file diff --git a/src/routes/Apps/App.scss b/src/routes/Apps/App.scss new file mode 100644 index 0000000..1dbe17e --- /dev/null +++ b/src/routes/Apps/App.scss @@ -0,0 +1,367 @@ +@use '../../assets/scss/variables' as *; +@use '../../assets/scss/mixins' as *; +@use '../../assets/scss/global' as *; +@use "sass:color"; + + +.app-detail-page { + @extend .page-wrapper; +} + +.app-detail-content { + @extend .page-content; +} + +// App header section +.app-header { + @include card; + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: $spacing-xl; + margin-bottom: $spacing-xl; + + .app-title-section { + flex: 1; + min-width: 300px; + + h1 { + margin: 0 0 $spacing-md 0; + font-size: $font-size-3xl; + color: $text-primary; + font-weight: $font-weight-bold; + } + + .app-meta { + display: flex; + gap: $spacing-sm; + flex-wrap: wrap; + } + } + + .app-actions { + display: flex; + gap: $spacing-md; + flex-wrap: wrap; + } +} + +// Action buttons +.action-btn { + @include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm); + + &.primary { + background: $primary; + color: $text-primary; + border-color: $primary; + + &:hover:not(:disabled) { + background: $primary-light; + border-color: $primary-light; + } + } + + &.secondary { + background: $bg-secondary; + color: $text-primary; + border-color: $border-color; + } + + &.danger { + background: $error; + color: white; + border-color: $error; + + &:hover:not(:disabled) { + background: color.adjust($error, $lightness: -10%); + } + } +} + +.back-button { + @extend .action-btn; + @extend .secondary; +} + +// Content grid layout +.content-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: $spacing-xl; + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + } +} + +// Info cards +.info-card { + @include card; + margin-bottom: $spacing-xl; + + &:last-child { + margin-bottom: 0; + } + + h2 { + margin: 0 0 $spacing-lg 0; + font-size: $font-size-xl; + color: $text-primary; + font-weight: $font-weight-bold; + } +} + +// Info grid +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: $spacing-lg; +} + +.info-item { + display: flex; + flex-direction: column; + gap: $spacing-xs; + + label { + font-size: $font-size-xs; + color: $text-secondary; + font-weight: $font-weight-medium; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + span { + font-size: $font-size-base; + color: $text-primary; + } + + .no-value { + color: $text-muted; + font-style: italic; + } + + .chaos-active { + background: color.adjust($info, $lightness: -10%); + font-weight: $font-weight-medium; + } +} + +.domain-link { + color: $primary-dark; + text-decoration: none; + font-size: $font-size-base; + transition: color $transition-base; + + &:hover { + color: $primary-light; + text-decoration: underline; + } +} + +.server-link { + background: none; + border: none; + color: $primary-dark; + cursor: pointer; + padding: 0; + font-size: $font-size-base; + text-align: left; + transition: color $transition-base; + + &:hover { + color: $primary-light; + text-decoration: underline; + } +} + +// Version information +.version-info { + display: flex; + flex-direction: column; + gap: $spacing-lg; + + .version-current { + label { + display: block; + font-size: $font-size-xs; + color: $text-secondary; + font-weight: $font-weight-medium; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: $spacing-sm; + } + + code { + display: inline-block; + background: $bg-secondary; + padding: $spacing-sm $spacing-md; + border-radius: $radius-sm; + font-size: $font-size-sm; + color: $text-primary; + font-family: monospace; + } + } + + .version-upgrades { + label { + display: flex; + align-items: center; + gap: $spacing-sm; + font-size: $font-size-xs; + color: $text-secondary; + font-weight: $font-weight-medium; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: $spacing-md; + + .upgrade-count { + background: $warning; + color: white; + padding: 2px 8px; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: $font-weight-bold; + } + } + + .upgrade-list { + display: flex; + flex-direction: column; + gap: $spacing-sm; + } + + .upgrade-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-md; + padding: $spacing-md; + background: $bg-secondary; + border-radius: $radius-md; + border: 2px solid transparent; + transition: border-color $transition-base; + + &:hover { + border-color: $warning; + } + + code { + flex: 1; + font-size: $font-size-sm; + color: $text-primary; + font-family: monospace; + } + + .upgrade-btn { + padding: $spacing-xs $spacing-md; + background: $warning; + color: white; + border: none; + border-radius: $radius-sm; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + cursor: pointer; + transition: all $transition-base; + + &:hover:not(:disabled) { + background: color.adjust($warning, $lightness: -10%); + transform: translateY(-1px); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + } + } + + .version-latest { + padding: $spacing-md; + background: rgba($success, 0.1); + background: color.adjust($success, $lightness: -10%); + border-radius: $radius-md; + text-align: center; + font-size: $font-size-base; + font-weight: $font-weight-medium; + } +} + +// Quick actions list +.action-list { + display: flex; + flex-direction: column; + gap: $spacing-sm; + + .action-list-item { + display: flex; + align-items: center; + gap: $spacing-md; + padding: $spacing-md; + background: $bg-secondary; + border: 2px solid transparent; + border-radius: $radius-md; + cursor: pointer; + transition: all $transition-base; + font-size: $font-size-sm; + color: $text-primary; + text-align: left; + width: 100%; + + &:hover:not(:disabled) { + background: white; + border-color: $primary; + transform: translateX(2px); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.danger { + &:hover:not(:disabled) { + border-color: $error; + background: rgba($error, 0.05); + } + } + + .action-text { + flex: 1; + font-weight: $font-weight-medium; + } + } +} + +// Health status +.health-status { + display: flex; + flex-direction: column; + gap: $spacing-md; + + .health-item { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: $spacing-md; + border-bottom: 1px solid $border-color; + + &:last-child { + border-bottom: none; + padding-bottom: 0; + } + + .health-label { + font-size: $font-size-sm; + color: $text-secondary; + } + + .health-value { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $text-primary; + } + } +} diff --git a/src/routes/Authenticated/Apps/App.tsx b/src/routes/Apps/App.tsx similarity index 71% rename from src/routes/Authenticated/Apps/App.tsx rename to src/routes/Apps/App.tsx index a71b48d..c67cbf8 100644 --- a/src/routes/Authenticated/Apps/App.tsx +++ b/src/routes/Apps/App.tsx @@ -1,8 +1,10 @@ import React, { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { Header } from '../../../components/Header/Header'; -import { apiService } from '../../../services/api'; -import type { AbraApp } from '../../../types'; +import { useParams, useNavigate, Link } 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'; export const AppDetail: React.FC = () => { @@ -12,8 +14,11 @@ export const AppDetail: React.FC = () => { const [app, setApp] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const [isEditing, setIsEditing] = useState(false); const [actionLoading, setActionLoading] = useState(null); + + // Terminal state + const [terminalLogs, setTerminalLogs] = useState([]); + const [terminalActive, setTerminalActive] = useState(false); const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; @@ -21,10 +26,9 @@ export const AppDetail: React.FC = () => { const fetchApp = async () => { try { if (isMockMode) { - const { mockApiService } = await import('../../../services/mockApi'); + const { mockApiService } = await import('../../services/mockApi'); const appsData = await mockApiService.getAppsGrouped(); - // Find the specific app const serverApps = appsData[server || '']; const foundApp = serverApps?.apps.find(a => a.appName === appName); @@ -34,7 +38,6 @@ export const AppDetail: React.FC = () => { setError('App not found'); } } else { - // Real API call would be here const appsData = await apiService.getAppsGrouped(); const serverApps = appsData[server || '']; const foundApp = serverApps?.apps.find(a => a.appName === appName); @@ -55,30 +58,55 @@ export const AppDetail: React.FC = () => { fetchApp(); }, [server, appName, isMockMode]); - const handleAction = async (action: string) => { + const handleAction = async (action: string, version?: string) => { if (!app) return; setActionLoading(action); + setTerminalActive(true); + setTerminalLogs([]); + try { - switch (action) { - case 'start': - await apiService.startApp(app.appName); - break; - case 'stop': - await apiService.stopApp(app.appName); - break; - case 'deploy': - await apiService.deployApp(app.appName); - break; - case 'upgrade': - // Upgrade logic would go here - console.log('Upgrade app'); - break; + if (isMockMode) { + const { mockApiService } = await import('../../services/mockApi'); + + const onLog = (log: LogEntry) => { + setTerminalLogs(prev => [...prev, log]); + }; + + switch (action) { + case 'deploy': + await mockApiService.deployApp(app.appName, onLog); + break; + case 'stop': + await mockApiService.stopApp(app.appName, onLog); + break; + case 'upgrade': + if (version) { + await mockApiService.upgradeApp(app.appName, version, onLog); + } + break; + case 'remove': + await mockApiService.removeApp(app.appName, onLog); + break; + } + } else { + // Real API calls + switch (action) { + case 'stop': + await apiService.stopApp(app.appName); + break; + case 'deploy': + await apiService.deployApp(app.appName); + break; + } } - // Refresh app data after action - // In real implementation, you'd refetch the app } catch (err) { console.error('Action failed:', err); + setTerminalLogs(prev => [...prev, { + type: 'error', + text: `❌ Error: ${err instanceof Error ? err.message : 'Action failed'}`, + timestamp: new Date() + }]); } finally { setActionLoading(null); } @@ -130,35 +158,36 @@ export const AppDetail: React.FC = () => { {app.recipe} {app.status} {app.chaos === 'true' && ( - Chaos + Chaos )}
-
+ {/* Terminal Component */} + setTerminalActive(false)} + /> +
{/* Left Column - Main Info */}
@@ -174,7 +203,7 @@ export const AppDetail: React.FC = () => {
{app.domain ? ( - + {app.domain} ↗ ) : ( @@ -183,13 +212,14 @@ export const AppDetail: React.FC = () => {
- - + {app.server} ↗ +
@@ -207,7 +237,7 @@ export const AppDetail: React.FC = () => {
- {app.chaos === 'true' ? '🔬 Enabled' : 'Disabled'} + {app.chaos === 'true' ? '☠️ Enabled' : 'Disabled'}
@@ -241,8 +271,8 @@ export const AppDetail: React.FC = () => { {version} @@ -254,49 +284,46 @@ export const AppDetail: React.FC = () => { {app.upgrade === 'latest' && (
- Running latest version + ✓ Running latest version
)}
- {/* Right Column - Actions & Logs */} + {/* Right Column - Actions & Stats */}

Quick Actions

+ - - - - - - -
@@ -323,4 +350,4 @@ export const AppDetail: React.FC = () => {
); -}; \ No newline at end of file +}; diff --git a/src/routes/Authenticated/Apps/Apps.scss b/src/routes/Apps/Apps.scss similarity index 58% rename from src/routes/Authenticated/Apps/Apps.scss rename to src/routes/Apps/Apps.scss index b1fdd1c..ead1337 100644 --- a/src/routes/Authenticated/Apps/Apps.scss +++ b/src/routes/Apps/Apps.scss @@ -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,10 +11,44 @@ @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 card; - overflow-x: auto; + @include scrollable-card; margin-bottom: $spacing-lg; } @@ -71,19 +105,19 @@ font-weight: $font-weight-medium; color: $text-primary; } -} - -.domain-link { - color: $primary; - text-decoration: none; - transition: color $transition-base; - - &:hover { - color: $primary-light; - text-decoration: underline; + .domain-link { + color: $primary-dark; + text-decoration: none; + transition: color $transition-base; + + &:hover { + color: $primary-light; + text-decoration: underline; + } } } + .no-domain { color: $text-muted; font-style: italic; @@ -112,23 +146,22 @@ gap: $spacing-sm; .action-btn { - 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; + @include action-btn(1px, $radius-sm, $spacing-xs $spacing-md, $font-size-sm); &:hover { - background-color: $bg-tertiary; - transform: scale(1.1); + background-color: $primary; + color: white; + border-color: $primary; } &.upgrade { border-color: $warning; color: $warning; + + &:hover { + background-color: $warning; + color: white; + } } } -} +} \ No newline at end of file diff --git a/src/routes/Authenticated/Apps/Apps.tsx b/src/routes/Apps/Apps.tsx similarity index 78% rename from src/routes/Authenticated/Apps/Apps.tsx rename to src/routes/Apps/Apps.tsx index f0aadb0..0fa9c0f 100644 --- a/src/routes/Authenticated/Apps/Apps.tsx +++ b/src/routes/Apps/Apps.tsx @@ -1,8 +1,13 @@ + +// 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 { 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 = () => { @@ -12,16 +17,16 @@ export const Apps: React.FC = () => { const [error, setError] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [filterServer, setFilterServer] = useState('all'); - const [filterStatus, setFilterStatus] = useState('all'); const [showUpgradesOnly, setShowUpgradesOnly] = useState(false); - + const [showChaosOnly, setShowChaosOnly] = useState(false); + const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; useEffect(() => { const fetchData = async () => { try { if (isMockMode) { - const { mockApiService } = await import('../../../services/mockApi'); + const { mockApiService } = await import('../../services/mockApi'); const data = await mockApiService.getAppsGrouped(); setAppsData(data); } else { @@ -59,7 +64,7 @@ export const Apps: React.FC = () => { return Object.keys(appsData); }, [appsData]); - // Filter apps + // Filter apps (additive filters: upgrades AND chaos can be applied together) const filteredApps = useMemo(() => { return allApps.filter(app => { const matchesSearch = @@ -68,14 +73,12 @@ export const Apps: React.FC = () => { (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 matchesChaos = !showChaosOnly || app.chaos === 'true'; const matchesUpgrade = !showUpgradesOnly || app.upgrade !== 'latest'; return matchesSearch && matchesServer && matchesChaos && matchesUpgrade; }); - }, [allApps, searchTerm, filterServer, filterStatus, showUpgradesOnly]); + }, [allApps, searchTerm, filterServer, showUpgradesOnly, showChaosOnly]); const stats = useMemo(() => { const total = allApps.length; @@ -86,6 +89,9 @@ export const Apps: React.FC = () => { return { total, needsUpgrade, chaosApps, totalServers }; }, [allApps, servers]); + const toggleUpgrades = () => setShowUpgradesOnly(prev => !prev); + const toggleChaos = () => setShowChaosOnly(prev => !prev); + if (loading) { return (
@@ -117,32 +123,37 @@ export const Apps: React.FC = () => {

{stats.total} apps across {stats.totalServers} servers

- {/* Stats Overview */} -
-
-
-

{stats.total}

-

Total Apps

-
-
-
-
-

{stats.needsUpgrade}

-

Upgrades Available

-
-
-
-
-

{stats.chaosApps}

-

Chaos Mode

-
-
-
-
-

{stats.totalServers}

-

Servers

-
-
+ {/* Compact Stats Overview */} +
+ + + + + +
{/* Filters */} @@ -161,21 +172,6 @@ export const Apps: React.FC = () => { ))} - - - -
{/* Apps Table */} @@ -189,7 +185,7 @@ export const Apps: React.FC = () => { Server Version Status - Actions + {/* Actions */} @@ -247,7 +243,7 @@ export const Apps: React.FC = () => { {app.status} - + {/*
)}
- + */} )) )} diff --git a/src/routes/Authenticated/Apps/App.scss b/src/routes/Authenticated/Apps/App.scss deleted file mode 100644 index 17a4c7a..0000000 --- a/src/routes/Authenticated/Apps/App.scss +++ /dev/null @@ -1,463 +0,0 @@ -@use '../../../assets/scss/variables' as *; -@use '../../../assets/scss/mixins' as *; -.app-detail-page { - min-height: 100vh; - background: #f5f5f5; -} - -.app-detail-content { - max-width: 1400px; - margin: 0 auto; - padding: 24px; - - .loading, - .error { - text-align: center; - padding: 60px 20px; - font-size: 16px; - color: #666; - } - - .error { - color: #dc3545; - } -} - -// Breadcrumb navigation -.breadcrumb { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 24px; - font-size: 14px; - - .breadcrumb-link { - background: none; - border: none; - color: #0066cc; - cursor: pointer; - padding: 0; - font-size: 14px; - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - - .breadcrumb-separator { - color: #999; - } - - .breadcrumb-current { - color: #333; - font-weight: 500; - } -} - -// App header section -.app-header { - background: white; - border-radius: 12px; - padding: 24px; - margin-bottom: 24px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - display: flex; - justify-content: space-between; - align-items: flex-start; - flex-wrap: wrap; - gap: 20px; - - .app-title-section { - flex: 1; - min-width: 300px; - - h1 { - margin: 0 0 12px 0; - font-size: 28px; - color: #1a1a1a; - } - - .app-meta { - display: flex; - gap: 8px; - flex-wrap: wrap; - } - } - - .app-actions { - display: flex; - gap: 12px; - flex-wrap: wrap; - } -} - -// Action buttons -.action-btn { - padding: 10px 20px; - border: none; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &.primary { - background: #0066cc; - color: white; - - &:hover:not(:disabled) { - background: #0052a3; - } - } - - &.secondary { - background: #f0f0f0; - color: #333; - - &:hover:not(:disabled) { - background: #e0e0e0; - } - } - - &.danger { - background: #dc3545; - color: white; - - &:hover:not(:disabled) { - background: #c82333; - } - } -} - -.back-button { - @extend .action-btn; - @extend .secondary; -} - -// Badges -.recipe-badge { - display: inline-block; - padding: 4px 12px; - background: #e7f3ff; - color: #0066cc; - border-radius: 4px; - font-size: 13px; - font-weight: 500; -} - -.status-badge { - display: inline-block; - padding: 4px 12px; - border-radius: 4px; - font-size: 13px; - font-weight: 500; - - &.status-deployed, - &.status-running { - background: #d4edda; - color: #155724; - } - - &.status-stopped { - background: #f8d7da; - color: #721c24; - } - - &.status-unknown { - background: #e2e3e5; - color: #383d41; - } -} - -.chaos-badge { - display: inline-block; - padding: 4px 12px; - background: #fff3cd; - color: #856404; - border-radius: 4px; - font-size: 13px; - font-weight: 500; -} - -// Content grid layout -.content-grid { - display: grid; - grid-template-columns: 2fr 1fr; - gap: 24px; - - @media (max-width: 1024px) { - grid-template-columns: 1fr; - } -} - -// Info cards -.info-card { - background: white; - border-radius: 12px; - padding: 24px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - margin-bottom: 24px; - - &:last-child { - margin-bottom: 0; - } - - h2 { - margin: 0 0 20px 0; - font-size: 18px; - color: #1a1a1a; - font-weight: 600; - } -} - -// Info grid -.info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 20px; -} - -.info-item { - display: flex; - flex-direction: column; - gap: 6px; - - label { - font-size: 12px; - color: #666; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - span { - font-size: 14px; - color: #1a1a1a; - } - - .no-value { - color: #999; - font-style: italic; - } - - .chaos-active { - color: #856404; - font-weight: 500; - } -} - -.domain-link { - color: $primary-dark; - text-decoration: none; - font-size: 14px; - - &:hover { - text-decoration: underline; - } -} - -.server-link { - background: none; - border: none; - color: #0066cc; - cursor: pointer; - padding: 0; - font-size: 14px; - text-align: left; - - &:hover { - text-decoration: underline; - } -} - -// Version information -.version-info { - display: flex; - flex-direction: column; - gap: 20px; - - .version-current { - label { - display: block; - font-size: 12px; - color: #666; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 8px; - } - - code { - display: inline-block; - background: #f5f5f5; - padding: 8px 12px; - border-radius: 4px; - font-size: 13px; - color: #1a1a1a; - } - } - - .version-upgrades { - label { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - color: #666; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 12px; - - .upgrade-count { - background: #ffc107; - color: #856404; - padding: 2px 8px; - border-radius: 12px; - font-size: 11px; - font-weight: 600; - } - } - - .upgrade-list { - display: flex; - flex-direction: column; - gap: 8px; - } - - .upgrade-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px; - background: #f8f9fa; - border-radius: 6px; - border: 1px solid #e9ecef; - - code { - flex: 1; - font-size: 13px; - color: #1a1a1a; - } - - .upgrade-btn { - padding: 6px 16px; - background: #ffc107; - color: #856404; - border: none; - border-radius: 4px; - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - - &:hover:not(:disabled) { - background: #e0a800; - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } - } - } - - .version-latest { - padding: 12px; - background: #d4edda; - color: #155724; - border-radius: 6px; - text-align: center; - font-size: 14px; - font-weight: 500; - } -} - -// Quick actions list -.action-list { - display: flex; - flex-direction: column; - gap: 8px; - - .action-list-item { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - background: #f8f9fa; - border: 1px solid #e9ecef; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; - font-size: 14px; - color: #1a1a1a; - text-align: left; - width: 100%; - - &:hover:not(:disabled) { - background: #e9ecef; - border-color: #dee2e6; - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &.danger { - color: #dc3545; - - &:hover:not(:disabled) { - background: #f8d7da; - border-color: #f5c6cb; - } - } - - .action-icon { - font-size: 18px; - } - - .action-text { - flex: 1; - } - } -} - -// Health status -.health-status { - display: flex; - flex-direction: column; - gap: 16px; - - .health-item { - display: flex; - justify-content: space-between; - align-items: center; - padding-bottom: 16px; - border-bottom: 1px solid #e9ecef; - - &:last-child { - border-bottom: none; - padding-bottom: 0; - } - - .health-label { - font-size: 14px; - color: #666; - } - - .health-value { - font-size: 16px; - font-weight: 600; - color: #1a1a1a; - } - } -} \ No newline at end of file diff --git a/src/routes/Authenticated/Dashboard/Dashboard.tsx b/src/routes/Dashboard/Dashboard.tsx similarity index 91% rename from src/routes/Authenticated/Dashboard/Dashboard.tsx rename to src/routes/Dashboard/Dashboard.tsx index 3788542..b8737f4 100644 --- a/src/routes/Authenticated/Dashboard/Dashboard.tsx +++ b/src/routes/Dashboard/Dashboard.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from 'react'; -import { Header } from '../../../components/Header/Header'; +import { Header } from '../../components/Header/Header'; import { useNavigate } from 'react-router-dom'; -import { apiService } from '../../../services/api'; -import type { AbraApp, AbraServer } from '../../../types'; +import { apiService } from '../../services/api'; +import type { AbraApp, AbraServer } from '../../types'; import './_Dashboard.scss'; export const Dashboard: React.FC = () => { @@ -19,7 +19,7 @@ export const Dashboard: React.FC = () => { 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(), @@ -89,7 +89,6 @@ export const Dashboard: React.FC = () => { - - - - ); -}; \ No newline at end of file diff --git a/src/routes/Recipes/Recipe.scss b/src/routes/Recipes/Recipe.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/Recipes/Recipe.tsx b/src/routes/Recipes/Recipe.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/Recipes/RecipeForm.scss b/src/routes/Recipes/RecipeForm.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/Recipes/RecipeForm.tsx b/src/routes/Recipes/RecipeForm.tsx new file mode 100644 index 0000000..dc3c7f9 --- /dev/null +++ b/src/routes/Recipes/RecipeForm.tsx @@ -0,0 +1,112 @@ +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([]); + const [selectedServer, setSelectedServer] = useState(""); // ❌ only one value + const [chaos, setChaos] = useState(false); + const [secrets, setSecrets] = useState(false); + 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 [formData, setFormData] = useState({ + domain: "", + chaos: false, + secrets: true, + }); + + const handleChange = (e) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value, + }); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + console.log("Submitting:", formData); + onClose(); + }; + + return ( +
+

{recipe.name}

+ { loading ? (

Loading servers...

+ ) : ( +
+ +
+ )} +
+ +
+
+ +
+ +
+ +
+ + + +
+ ); +} + +export default RecipeForm; \ No newline at end of file diff --git a/src/routes/Recipes/Recipes.scss b/src/routes/Recipes/Recipes.scss new file mode 100644 index 0000000..df6348c --- /dev/null +++ b/src/routes/Recipes/Recipes.scss @@ -0,0 +1,164 @@ +@use '../../assets/scss/variables' as *; +@use '../../assets/scss/mixins' as *; +@use '../../assets/scss/global' as *; + +// Extend global page wrapper +.recipes-page { + @extend .page-wrapper; +} + +.recipes-content { + @extend .page-content; +} + +// Servers grid +.recipes-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: $spacing-xl; + margin-bottom: $spacing-xl; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +// Server card +.recipe-card { + @include card; + @include card-dimensions(320px, 180px); + display: grid; + grid-template-rows: 1fr 2fr auto 0.9fr; + transition: transform $transition-base, box-shadow $transition-base; + position: relative; + overflow: hidden; + + &:hover { + transform: translateY(-4px); + box-shadow: $shadow-xl; + } + + .recipe-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: $spacing-lg; + padding-bottom: $spacing-md; + border-bottom: 2px solid $bg-secondary; + + .recipe-title { + h3 { + margin: 0 0 $spacing-xs; + font-size: $font-size-xl; + color: $text-primary; + font-weight: $font-weight-bold; + } + + .recipe-host { + font-size: $font-size-sm; + color: $text-muted; + font-family: monospace; + } + } + img { + width: clamp(20px, 3vw, 32px); + height: clamp(20px, 3vw, 32px); + object-fit: contain; + } + } + .card-tags { + display: flex; + gap: 6px; + margin-top: $spacing-sm; + height: $spacing-md; + flex-wrap: wrap; + } + .tag-category { + font-size: 12px; + padding: 2px 8px; + border-radius: 5px; + color: rgb(255, 255, 255); + font-weight: 700; + background-color: rgb(111, 128, 255); + + } + .tag-feature { + font-size: 12px; + padding: 2px 8px; + border-radius: 5px; + color: rgb(255, 255, 255); + font-weight: 700; + background-color: rgb(22, 180, 22); + + } + .recipe-stats { + flex-grow: 1; + flex: 1; + margin-bottom: $spacing-lg; + + .stat-row { + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + + } + } + + .recipe-actions { + display: flex; + gap: $spacing-sm; + .action-btn { + flex: 1; + @include action-btn(2px, $radius-md, $spacing-sm $spacing-md, $font-size-sm); + + &.primary { + background-color: $primary; + color: white; + border-color: $primary; + + &:hover { + background-color: $primary-light; + border-color: $primary-light; + } + } + } + } + + .recipe-alert { + background-color: rgba($warning, 0.1); + border-left: 4px solid $warning; + padding: $spacing-sm $spacing-md; + margin: 0 (-$spacing-xl) (-$spacing-xl); + display: flex; + align-items: center; + gap: $spacing-sm; + + .alert-icon { + font-size: $font-size-lg; + color: $warning; + } + + .alert-text { + font-size: $font-size-sm; + color: $text-primary; + font-weight: $font-weight-medium; + } + } +} +.modal-overlay { +position: fixed; +inset: 0; +background: rgba(0, 0, 0, 0.4); +display: flex; +justify-content: center; +align-items: center; +} + +.modal { + background: white; + padding: 24px; + border-radius: 8px; + width: min(90%, 500px); +} diff --git a/src/routes/Recipes/Recipes.tsx b/src/routes/Recipes/Recipes.tsx new file mode 100644 index 0000000..f8b9005 --- /dev/null +++ b/src/routes/Recipes/Recipes.tsx @@ -0,0 +1,192 @@ +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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [filterServer, setFilterServer] = useState('all'); + const [filterStatus, setFilterStatus] = useState('all'); + const [selectedRecipe, setSelectedRecipe] = useState(null); + const [showForm, setShowForm] = useState(false); + + + + const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; + + useEffect(() => { + const fetchData = async () => { + try { + if (isMockMode) { + const { mockApiService } = await import('../../services/mockApi'); + const data = await mockApiService.getRecipes(); + console.log(data) + setRecipesData(data); + } else { + const data = await apiService.getRecipes(); + setRecipesData(data); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load apps'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [isMockMode]); + + // Flatten and enrich apps data + const allRecipes: AbraRecipe[] = useMemo(() => { + if (!recipesData) return []; + + return recipesData; + }, [recipesData]); + + + // Filter apps + const filteredRecipes = useMemo(() => { + return allRecipes.filter(recipe => { + const matchesSearch = + recipe.name.toLowerCase().includes(searchTerm.toLowerCase()); + + return matchesSearch; + }); + }, [allRecipes, searchTerm, filterServer, filterStatus]); + + const stats = useMemo(() => { + const total = allRecipes.length; + + return { total }; + }, [allRecipes]); + + if (loading) { + return ( +
+
+
+
Loading applications...
+
+
+ ); + } + + if (error) { + return ( +
+
+
+
Error: {error}
+
+
+ ); + } + + return ( +
+
+
+
+

Applications

+

{stats.total} recipes

+
+ + {/* Stats Overview */} +
+
+
+

{stats.total}

+

Total Recipes

+
+
+
+ + {/* Filters */} +
+ setSearchTerm(e.target.value)} + className="search-input" + /> + + + +
+ + {/* Server Cards */} +
+ {filteredRecipes.length === 0 ? ( +
No recipes found matching your search
+ ) : ( + filteredRecipes.map((recipe) => ( +
+
+
+

{recipe.name}

+
+
+ {recipe.icon.length > 0 ? : null } +
+
+ +
+
+ {recipe.description} +
+
+ +
+ +
+
+ {recipe.features.backups.toLowerCase().includes("yes") ? Backups : null} + {recipe.features.healthcheck.toLowerCase().includes("yes") ? Healthcheck : null} + {recipe.category.length > 0 ? {recipe.category} : null} +
+
+ )) + )} +
+ {selectedRecipe && ( +
setSelectedRecipe(null)}> +
e.stopPropagation()}> + {} + setSelectedRecipe(null)} /> +
+
+ )} + +
+ Showing {filteredRecipes.length} of {allRecipes.length} apps +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/routes/Authenticated/Servers/Server.scss b/src/routes/Servers/Server.scss similarity index 66% rename from src/routes/Authenticated/Servers/Server.scss rename to src/routes/Servers/Server.scss index aba923f..ea53d47 100644 --- a/src/routes/Authenticated/Servers/Server.scss +++ b/src/routes/Servers/Server.scss @@ -1,68 +1,14 @@ -@use '../../../assets/scss/variables' as *; -@use '../../../assets/scss/mixins' as *; +@use '../../assets/scss/variables' as *; +@use '../../assets/scss/mixins' as *; +@use '../../assets/scss/global' as *; +@use "sass:color"; .server-detail-page { - min-height: 100vh; - background-color: $bg-secondary; + @extend .page-wrapper; } .server-detail-content { - max-width: 1600px; - margin: 0 auto; - padding: $spacing-2xl $spacing-xl; - - @media (max-width: 768px) { - padding: $spacing-xl $spacing-md; - } - - .loading, - .error { - text-align: center; - padding: $spacing-3xl; - font-size: $font-size-lg; - } - - .loading { - color: $text-secondary; - } - - .error { - color: $error; - } -} - -// Breadcrumb navigation -.breadcrumb { - display: flex; - align-items: center; - gap: $spacing-sm; - margin-bottom: $spacing-xl; - font-size: $font-size-sm; - - .breadcrumb-link { - background: none; - border: none; - color: $primary; - cursor: pointer; - padding: 0; - font-size: $font-size-sm; - text-decoration: none; - transition: color $transition-base; - - &:hover { - color: $primary-light; - text-decoration: underline; - } - } - - .breadcrumb-separator { - color: $text-muted; - } - - .breadcrumb-current { - color: $text-primary; - font-weight: $font-weight-semibold; - } + @extend .page-content; } // Server header section @@ -100,27 +46,9 @@ } } -// Action buttons +// Action buttons (shared with App view, could be moved to global) .action-btn { - 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; - } + @include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm); &.primary { background: $primary; @@ -139,7 +67,7 @@ border-color: $border-color; &:hover:not(:disabled) { - background: darken($bg-secondary, 5%); + background: color.adjust($bg-secondary, $lightness: -10%); } } @@ -149,7 +77,7 @@ border-color: $error; &:hover:not(:disabled) { - background: darken($error, 10%); + background: color.adjust($error, $lightness: -10%); } } } @@ -159,7 +87,7 @@ @extend .secondary; } -// Badges +// Badges - host badge is specific to server view .host-badge { display: inline-flex; align-items: center; @@ -179,56 +107,12 @@ gap: $spacing-xs; padding: $spacing-xs $spacing-md; background: rgba($warning, 0.1); - color: darken($warning, 20%); + background: color.adjust($error, $lightness: -20%); border-radius: $radius-md; font-size: $font-size-sm; font-weight: $font-weight-medium; } -.recipe-badge { - display: inline-block; - padding: $spacing-xs $spacing-sm; - background: rgba($primary, 0.1); - color: $primary-light; - border-radius: $radius-sm; - font-size: $font-size-xs; - font-weight: $font-weight-medium; -} - -.status-badge { - display: inline-block; - padding: $spacing-xs $spacing-sm; - border-radius: $radius-sm; - font-size: $font-size-xs; - font-weight: $font-weight-medium; - - &.status-deployed, - &.status-running { - background: rgba($success, 0.1); - color: darken($success, 10%); - } - - &.status-stopped { - background: rgba($error, 0.1); - color: darken($error, 10%); - } - - &.status-unknown { - background: $bg-secondary; - color: $text-muted; - } -} - -.chaos-badge { - display: inline-block; - padding: $spacing-xs $spacing-sm; - background: rgba($info, 0.1); - color: darken($info, 10%); - border-radius: $radius-sm; - font-size: $font-size-xs; - font-weight: $font-weight-medium; -} - // Content grid layout .content-grid { display: grid; @@ -288,11 +172,11 @@ color: $text-primary; &.warning { - color: darken($warning, 10%); + background: color.adjust($warning, $lightness: -10%); } &.chaos { - color: darken($info, 10%); + background: color.adjust($info, $lightness: -10%); } } @@ -311,9 +195,7 @@ } .apps-list { - display: flex; - flex-direction: column; - gap: $spacing-md; + @include card-list-vertical; } .app-item { @@ -405,18 +287,12 @@ } &.danger { - color: $error; - &:hover:not(:disabled) { border-color: $error; background: rgba($error, 0.05); } } - .action-icon { - font-size: $font-size-lg; - } - .action-text { flex: 1; font-weight: $font-weight-medium; @@ -461,4 +337,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/routes/Authenticated/Servers/Server.tsx b/src/routes/Servers/Server.tsx similarity index 83% rename from src/routes/Authenticated/Servers/Server.tsx rename to src/routes/Servers/Server.tsx index 0cab409..f7c06a8 100644 --- a/src/routes/Authenticated/Servers/Server.tsx +++ b/src/routes/Servers/Server.tsx @@ -1,8 +1,10 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Header } from '../../../components/Header/Header'; -import { apiService } from '../../../services/api'; -import type { AbraServer, ServerAppsResponse } from '../../../types'; +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 { @@ -22,6 +24,10 @@ export const Server: React.FC = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [actionLoading, setActionLoading] = useState(null); + + // Terminal state + const [terminalLogs, setTerminalLogs] = useState([]); + const [terminalActive, setTerminalActive] = useState(false); const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; @@ -29,11 +35,10 @@ export const Server: React.FC = () => { 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(); - // Find the server const foundServer = serversData.find(s => s.name === serverName); const serverApps = appsData[serverName || '']; @@ -51,7 +56,6 @@ export const Server: React.FC = () => { setError('Server not found'); } } else { - // Real API call const appsData = await apiService.getAppsGrouped(); const serversData = await apiService.getServers(); @@ -86,11 +90,39 @@ export const Server: React.FC = () => { if (!server) return; setActionLoading(action); + setTerminalActive(true); + setTerminalLogs([]); + try { - console.log(`Action: ${action} on server ${server.name}`); - // Add actual server actions here + if (isMockMode) { + const { mockApiService } = await import('../../services/mockApi'); + + const onLog = (log: LogEntry) => { + setTerminalLogs(prev => [...prev, log]); + }; + + switch (action) { + case 'refresh': + await mockApiService.refreshServer(server.name, onLog); + break; + case 'deploy-all': + await mockApiService.deployAllApps(server.name, server.appCount, onLog); + break; + case 'upgrade-all': + await mockApiService.upgradeAllApps(server.name, server.upgradeCount, onLog); + break; + } + } else { + // Real API calls + console.log(`Action: ${action} on server ${server.name}`); + } } catch (err) { console.error('Action failed:', err); + setTerminalLogs(prev => [...prev, { + type: 'error', + text: `❌ Error: ${err instanceof Error ? err.message : 'Action failed'}`, + timestamp: new Date() + }]); } finally { setActionLoading(null); } @@ -153,20 +185,27 @@ export const Server: React.FC = () => { + {/* Terminal Component */} + setTerminalActive(false)} + /> +
{/* Left Column - Main Info */}
@@ -241,10 +280,10 @@ export const Server: React.FC = () => { {app.status} {app.chaos === 'true' && ( - + ☠️ )} {app.upgrade !== 'latest' && ( - + ⬆️ )}
@@ -278,7 +317,7 @@ export const Server: React.FC = () => { @@ -286,7 +325,7 @@ export const Server: React.FC = () => { @@ -294,26 +333,10 @@ export const Server: React.FC = () => { - - - - - - - - @@ -322,7 +345,7 @@ export const Server: React.FC = () => {
Status - Online + 🟢 Online
CPU Usage @@ -347,4 +370,4 @@ export const Server: React.FC = () => {
); -}; \ No newline at end of file +}; diff --git a/src/routes/Authenticated/Servers/Servers.scss b/src/routes/Servers/Servers.scss similarity index 54% rename from src/routes/Authenticated/Servers/Servers.scss rename to src/routes/Servers/Servers.scss index d7224ae..a6ec97c 100644 --- a/src/routes/Authenticated/Servers/Servers.scss +++ b/src/routes/Servers/Servers.scss @@ -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,10 +14,12 @@ // Servers grid .servers-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: $spacing-xl; margin-bottom: $spacing-xl; + align-items: stretch; + @media (max-width: 768px) { grid-template-columns: 1fr; } @@ -26,38 +28,18 @@ // 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 { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: $spacing-lg; - padding-bottom: $spacing-md; - border-bottom: 2px solid $bg-secondary; + @include card-header-rule; .server-title { - 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; - } + @include card-title-stack; } .server-status { @@ -80,24 +62,12 @@ margin-bottom: $spacing-lg; .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; - } + @include card-stat-row; // 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; @@ -109,11 +79,8 @@ } &.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; @@ -152,26 +119,12 @@ .action-btn { flex: 1; - 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); - } + @include action-btn(2px, $radius-md, $spacing-sm $spacing-md, $font-size-sm); &.primary { - background-color: $primary; - color: white; - border-color: $primary; + // background-color: $primary; + // color: white; + // border-color: $primary; &:hover { background-color: $primary-light; diff --git a/src/routes/Authenticated/Servers/Servers.tsx b/src/routes/Servers/Servers.tsx similarity index 55% rename from src/routes/Authenticated/Servers/Servers.tsx rename to src/routes/Servers/Servers.tsx index 95c369a..6ff2219 100644 --- a/src/routes/Authenticated/Servers/Servers.tsx +++ b/src/routes/Servers/Servers.tsx @@ -1,8 +1,8 @@ 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 { Header } from '../../components/Header/Header'; +import { apiService } from '../../services/api'; +import type { AbraServer, ServerAppsResponse } from '../../types'; import './Servers.scss'; interface ServerWithStats extends AbraServer { @@ -20,59 +20,47 @@ export const Servers: React.FC = () => { const [error, setError] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState<'name' | 'apps' | 'upgrades'>('name'); + const [showUpgradesOnly, setShowUpgradesOnly] = useState(false); + const [showChaosOnly, setShowChaosOnly] = useState(false); const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; useEffect(() => { const fetchData = async () => { try { + let serversData, appsData; + if (isMockMode) { - const { mockApiService } = await import('../../../services/mockApi'); - const [serversData, appsData] = await Promise.all([ + const { mockApiService } = await import('../../services/mockApi'); + [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 { - const [serversData, appsData] = await Promise.all([ + [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'); } finally { setLoading(false); @@ -92,28 +80,29 @@ export const Servers: React.FC = () => { return { totalServers, totalApps, totalUpgrades, totalChaos }; }, [servers]); - // Filter and sort servers + // Filter and sort servers (additive filters allowed) const filteredServers = useMemo(() => { - const filtered = servers.filter(server => + const filtered = servers.filter(server => { + const matchesSearch = server.name.toLowerCase().includes(searchTerm.toLowerCase()) || - server.host.toLowerCase().includes(searchTerm.toLowerCase()) - ); + server.host.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesUpgrades = !showUpgradesOnly || server.upgradeCount > 0; + const matchesChaos = !showChaosOnly || server.chaosCount > 0; + return matchesSearch && matchesUpgrades && matchesChaos; + }); - // Sort - filtered.sort((a, b) => { - switch (sortBy) { - case 'apps': - return b.appCount - a.appCount; - case 'upgrades': - return b.upgradeCount - a.upgradeCount; - case 'name': - default: - return a.name.localeCompare(b.name); - } - }); + filtered.sort((a, b) => { + switch (sortBy) { + case 'apps': + return b.appCount - a.appCount; + case 'name': + default: + return a.name.localeCompare(b.name); + } + }); - return filtered; - }, [servers, searchTerm, sortBy]); + return filtered; +}, [servers, searchTerm, sortBy, showUpgradesOnly]); if (loading) { return ( @@ -146,36 +135,38 @@ export const Servers: React.FC = () => {

Managing {stats.totalServers} servers with {stats.totalApps} applications

- {/* Stats Overview */} -
-
-
-
-

{stats.totalServers}

-

Total Servers

-
-
-
-
-
-

{stats.totalApps}

-

Total Apps

-
-
-
-
-
-

{stats.totalUpgrades}

-

Apps Need Upgrade

-
-
-
-
-
-

{stats.totalChaos}

-

Chaos Apps

-
-
+ {/* Compact Stats Row */} +
+ + + + + + + {/* Clear filters button removed — use stat-chip outlines for active filters */}
{/* Filters */} @@ -187,12 +178,6 @@ export const Servers: React.FC = () => { onChange={(e) => setSearchTerm(e.target.value)} className="search-input" /> - -
{/* Server Cards */} @@ -233,7 +218,7 @@ export const Servers: React.FC = () => { {server.upgradeCount > 0 && (
- ⬆️ Need Upgrade + Need Upgrade {server.upgradeCount}
@@ -241,37 +226,15 @@ export const Servers: React.FC = () => { {server.chaosCount > 0 && (
- ☠️ Chaos Mode + Chaos Mode {server.chaosCount}
)} -
- - -
- {server.upgradeCount > 0 && (
- ⚠️ {server.upgradeCount} app{server.upgradeCount > 1 ? 's' : ''} can be upgraded @@ -288,4 +251,4 @@ export const Servers: React.FC = () => {
); -}; \ No newline at end of file +}; diff --git a/src/services/api.ts b/src/services/api.ts index 2447568..2f55c2d 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,15 +1,24 @@ -import type { AbraApp, AbraServer, AuthResponse, LoginCredentials, User } from '../types'; +import type { AbraServer, AbraRecipe, ServerAppsResponse } from '../types'; + +// Log entry type +export type LogEntry = { + type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output'; + text: string; + timestamp: Date; +}; const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'; +// Helper to process log JSON from API response +const processLogResponse = (logData: any[]): LogEntry[] => { + return logData.map(log => ({ + type: log.type, + text: log.text, + timestamp: new Date(log.timestamp || Date.now()), + })); +}; + class ApiService { - private token: string | null = null; - - constructor() { - // Load token from localStorage on initialization - this.token = localStorage.getItem('auth_token'); - } - private async request( endpoint: string, options: RequestInit = {} @@ -19,10 +28,6 @@ class ApiService { ...options.headers, }; - if (this.token) { - headers['Authorization'] = `Bearer ${this.token}`; - } - const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers, @@ -36,53 +41,100 @@ class ApiService { return response.json(); } - // Auth methods - async login(credentials: LoginCredentials): Promise { - const response = await this.request('/auth/login', { - method: 'POST', - body: JSON.stringify(credentials), - }); - - this.token = response.token; - localStorage.setItem('auth_token', response.token); - return response; - } - - async logout(): Promise { - this.token = null; - localStorage.removeItem('auth_token'); - } - - async getCurrentUser(): Promise { - return this.request('/auth/me'); - } - - // Abra CLI wrapper methods + // Get all apps grouped by server async getAppsGrouped(): Promise { - return this.request('/abra/apps'); + return this.request('/apps'); } + // Get all servers async getServers(): Promise { - return this.request('/abra/servers'); + return this.request('/servers'); } - async deployApp(appName: string): Promise { - return this.request(`/abra/apps/${appName}/deploy`, { + // App actions with log streaming (websocket future) + async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/apps/${appName}/deploy`, { method: 'POST', }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } } - async stopApp(appName: string): Promise { - return this.request(`/abra/apps/${appName}/stop`, { + async undeployApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/apps/${appName}/undeploy`, { method: 'POST', }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } } - async startApp(appName: string): Promise { - return this.request(`/abra/apps/${appName}/start`, { + async upgradeApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise { + 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)); + } + } + + async removeApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + 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)); + } + } + + // Server actions with log streaming + async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/refresh`, { + method: 'POST', + }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } + } + + async deployAllApps(serverName: string, appCount: number, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/deploy-all`, { + method: 'POST', + }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } + } + + async upgradeAllApps(serverName: string, upgradeCount: number, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/upgrade-all`, { + method: 'POST', + }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } + } + // recipe catalog imports + async getRecipes(): Promise { + return this.request('/abra/catalogue'); } } + export const apiService = new ApiService(); \ No newline at end of file diff --git a/src/services/mock-catalogue.json b/src/services/mock-catalogue.json new file mode 100644 index 0000000..3d11e40 --- /dev/null +++ b/src/services/mock-catalogue.json @@ -0,0 +1,1726 @@ +[ + { + "category": "Apps", + "default_branch": "main", + "description": "An awesome, Online Office suite image suitable for home use.", + "features": { + "backups": "No", + "email": "3", + "healthcheck": "No", + "image": { + "image": "collabora", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/r/collabora/code" + }, + "status": 3, + "tests": "2", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/249-0937028d43099df58d85bc41a7e69fbd", + "name": "collabora", + "repository": "https://git.coopcloud.tech/coop-cloud/collabora.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/collabora.git", + "versions": [ + { + "1.0.0+6.4.9.3": { + "app": { + "image": "collabora/code", + "tag": "6.4.9.3" + }, + "web": { + "image": "nginx", + "tag": "1.21.4" + } + } + }, + { + "2.0.0+21.11.0.5.1": { + "app": { + "image": "collabora/code", + "tag": "21.11.0.5.1" + }, + "web": { + "image": "nginx", + "tag": "1.21.4" + } + } + }, + { + "2.1.0+21.11.1.4.1": { + "app": { + "image": "collabora/code", + "tag": "21.11.1.4.1" + }, + "web": { + "image": "nginx", + "tag": "1.21.4" + } + } + }, + { + "2.1.2+22.05.8.2.1": { + "app": { + "image": "collabora/code", + "tag": "22.05.8.2.1" + }, + "web": { + "image": "nginx", + "tag": "1.22.1" + } + } + }, + { + "2.2.0+22.05.10.1.1": { + "app": { + "image": "collabora/code", + "tag": "22.05.10.1.1" + }, + "web": { + "image": "nginx", + "tag": "1.23.3" + } + } + }, + { + "2.3.0+22.05.14.3.1": { + "app": { + "image": "collabora/code", + "tag": "22.05.14.3.1" + }, + "web": { + "image": "nginx", + "tag": "1.24.0" + } + } + }, + { + "2.4.0+23.05.2.2.1": { + "app": { + "image": "collabora/code", + "tag": "23.05.2.2.1" + }, + "web": { + "image": "nginx", + "tag": "1.25.2" + } + } + }, + { + "2.5.0+23.05.3.1.1": { + "app": { + "image": "collabora/code", + "tag": "23.05.3.1.1" + }, + "web": { + "image": "nginx", + "tag": "1.25.2" + } + } + }, + { + "2.6.0+23.05.5.4.1": { + "app": { + "image": "collabora/code", + "tag": "23.05.5.4.1" + }, + "web": { + "image": "nginx", + "tag": "1.25.3" + } + } + }, + { + "2.7.0+23.05.8.2.1": { + "app": { + "image": "collabora/code", + "tag": "23.05.8.2.1" + }, + "web": { + "image": "nginx", + "tag": "1.25.4" + } + } + }, + { + "2.7.1+23.05.10.1.1": { + "app": { + "image": "collabora/code", + "tag": "23.05.10.1.1" + }, + "web": { + "image": "nginx", + "tag": "1.25.4" + } + } + }, + { + "3.0.0+24.04.6.1.1": { + "app": { + "image": "collabora/code", + "tag": "24.04.6.1.1" + }, + "web": { + "image": "nginx", + "tag": "1.27.0" + } + } + }, + { + "3.1.0+24.04.10.2.1": { + "app": { + "image": "collabora/code", + "tag": "24.04.10.2.1" + }, + "web": { + "image": "nginx", + "tag": "1.27.3" + } + } + }, + { + "3.2.0+24.04.12.3.1": { + "app": { + "image": "collabora/code", + "tag": "24.04.12.3.1" + }, + "web": { + "image": "nginx", + "tag": "1.27.4" + } + } + }, + { + "3.3.0+24.04.13.3.1": { + "app": { + "image": "collabora/code", + "tag": "24.04.13.3.1" + }, + "web": { + "image": "nginx", + "tag": "1.28.0" + } + } + }, + { + "4.0.0+25.04.4.1.1": { + "app": { + "image": "collabora/code", + "tag": "25.04.4.1.1" + }, + "web": { + "image": "nginx", + "tag": "1.29.0" + } + } + }, + { + "4.0.1+25.04.9.1.1": { + "app": { + "image": "collabora/code", + "tag": "25.04.9.1.1" + }, + "web": { + "image": "nginx", + "tag": "1.29.4" + } + } + }, + { + "4.0.2+25.04.9.4.1": { + "app": { + "image": "collabora/code", + "tag": "25.04.9.4.1" + }, + "web": { + "image": "nginx", + "tag": "1.29.7" + } + } + } + ], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "HTTP/HTTPS compression proxy ", + "features": { + "backups": "No", + "email": "N/A", + "healthcheck": "No", + "image": { + "image": "compy", + "rating": "0", + "source": "own", + "url": "https://hub.docker.com/r/thecoopcloud/compy" + }, + "status": 1, + "tests": "No", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/435-127e3f583ac79a3b71f8fbd9c1cf5ba1", + "name": "compy", + "repository": "https://git.coopcloud.tech/coop-cloud/compy.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/compy.git", + "versions": [], + "website": "" + }, + { + "category": "Development", + "default_branch": "main", + "description": "A painless self-hosted Git service", + "features": { + "backups": "Yes", + "email": "Yes", + "healthcheck": "Yes", + "image": { + "image": "forgejo/forgejo", + "rating": "4", + "source": "upstream", + "url": "https://codeberg.org/forgejo/-/packages/container/forgejo/13-rootless" + }, + "status": 5, + "tests": "2", + "sso": "3 (OAuth)" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/eb3fb3ee823159765f746a75128143f3cd9b11c3f074ff6043ceeac8cdc434a9", + "name": "forgejo", + "repository": "https://git.coopcloud.tech/coop-cloud/forgejo.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/forgejo.git", + "versions": [ + { + "1.0.0+1.14.5-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.14.5-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.1.0+1.15.0-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.15.0-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.1.1+1.15.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.15.3-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.1.2+1.15.6-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.15.6-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.1.3+1.15.10-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.15.10-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.2.0+1.16.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.16.3-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.2.1+1.16.8-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.16.8-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.3.0+1.17.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.17.2-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.3.1+1.17.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.17.3-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.9" + } + } + }, + { + "2.0.0+1.18.0-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.18.0-rootless" + }, + "db": { + "image": "postgres", + "tag": "9.6" + } + } + }, + { + "2.0.1+1.18.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.18.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "9.6" + } + } + }, + { + "2.1.0+1.18.5-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.18.5-rootless" + }, + "db": { + "image": "postgres", + "tag": "9.6" + } + } + }, + { + "2.1.2+1.19.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.19.3-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.2" + } + } + }, + { + "2.2.0+1.19.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.19.3-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.3" + } + } + }, + { + "2.3.0+1.20.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.20.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.3" + } + } + }, + { + "2.3.1+1.20.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.20.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.3" + } + } + }, + { + "2.3.2+1.20.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.20.3-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.4" + } + } + }, + { + "2.3.3+1.20.5-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.20.5-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.4" + } + } + }, + { + "2.4.0+1.21.0-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.0-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.4" + } + } + }, + { + "2.5.0+1.21.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.5" + } + } + }, + { + "2.5.1+1.21.4-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.4-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.5" + } + } + }, + { + "2.5.2+1.21.5-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.5-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.5" + } + } + }, + { + "2.6.0+1.21.5-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.5-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.5" + } + } + }, + { + "2.6.1+1.21.10-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.10-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.5" + } + } + }, + { + "2.6.2+1.21.10-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.10-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.5" + } + } + }, + { + "2.7.0+1.21.11-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.11-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.6" + } + } + }, + { + "2.8.0+1.21.11-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.11-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.7" + } + } + }, + { + "2.9.0+1.22.0-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.0-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.7" + } + } + }, + { + "2.9.1+1.22.0-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.0-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.7" + } + } + }, + { + "2.10.0+1.22.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.7" + } + } + }, + { + "2.10.1+1.22.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.8" + } + } + }, + { + "2.11.0+1.22.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.8" + } + } + }, + { + "3.0.0+1.22.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.8" + } + } + }, + { + "3.0.1+1.22.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.3-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.8" + } + } + }, + { + "3.0.2+1.22.6-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.6-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.0.3+1.22.6-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.6-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.1.0+1.23.0-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.23.0-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.1.1+1.23.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.23.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.2.0+1.23.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.23.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.3.0+1.23.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.23.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.3.1+1.23.8-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.23.8-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.4.0+1.24.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.24.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "3.5.0+1.24.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.24.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "3.5.1+1.24.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.24.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "3.5.2+1.24.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.24.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "4.0.0+12.0.2-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "12.0.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "4.0.1+12.0.2-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "12.0.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "4.0.2+12.0.2-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "12.0.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "5.0.0+13.0.2-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "13.0.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "5.0.1+13.0.3-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "13.0.3-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "5.0.2+13.0.4-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "13.0.4-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "5.0.3+13.0.4-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "13.0.4-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "5.1.0+14.0.1-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "14.0.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "5.1.1+14.0.3-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "14.0.3-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + } + ], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "A shared agenda for local communities", + "features": { + "backups": "No", + "email": "No", + "healthcheck": "No", + "image": { + "image": "gancio", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/cisti/gancio" + }, + "status": 0, + "tests": "No", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/485-f4a447197439adc13c2440728f1ba8b6", + "name": "gancio", + "repository": "https://git.coopcloud.tech/coop-cloud/gancio.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/gancio.git", + "versions": [ + { + "0.1.0+1.25.1": { + "app": { + "image": "cisti/gancio", + "tag": "1.25.1" + } + } + } + ], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "A federated link aggregator in Rust", + "features": { + "backups": "No", + "email": "No", + "healthcheck": "Yes", + "image": { + "image": "lemmy", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/r/dessalines/lemmy" + }, + "status": 1, + "tests": "2", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/432-5751cb07a13ead1bea8249a8f3b5ae68", + "name": "lemmy", + "repository": "https://git.coopcloud.tech/coop-cloud/lemmy.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/lemmy.git", + "versions": [ + { + "0.1.0+0.16.6": { + "app": { + "image": "dessalines/lemmy", + "tag": "0.16.6" + }, + "db": { + "image": "postgres", + "tag": "12-alpine" + }, + "pictrs": { + "image": "asonix/pictrs", + "tag": "0.3.0-beta.12-r1" + }, + "ui": { + "image": "dessalines/lemmy-ui", + "tag": "0.16.6" + }, + "web": { + "image": "nginx", + "tag": "1.20.0" + } + } + }, + { + "0.1.0+0.16.1": { + "app": { + "image": "dessalines/lemmy", + "tag": "0.16.1" + }, + "db": { + "image": "postgres", + "tag": "12-alpine" + }, + "pictrs": { + "image": "asonix/pictrs", + "tag": "0.3.0-beta.12-r1" + }, + "ui": { + "image": "dessalines/lemmy-ui", + "tag": "0.16.1" + }, + "web": { + "image": "nginx", + "tag": "1.20.0" + } + } + }, + { + "0.2.0+0.18.3": { + "app": { + "image": "dessalines/lemmy", + "tag": "0.18.3" + }, + "db": { + "image": "postgres", + "tag": "15-alpine" + }, + "pictrs": { + "image": "asonix/pictrs", + "tag": "0.4.0-beta.19" + }, + "ui": { + "image": "dessalines/lemmy-ui", + "tag": "0.18.3" + }, + "web": { + "image": "nginx", + "tag": "1.20.0" + } + } + } + ], + "website": "" + }, + { + "category": "Utilities", + "default_branch": "main", + "description": "Spin up an onion service for your Co-op Cloud applications", + "features": { + "backups": "Yes", + "email": "N/A", + "healthcheck": "Yes", + "image": { + "image": "onimages/tor:alpine", + "rating": "4", + "source": "upstream", + "url": "https://onionservices.torproject.org/apps/base/containers" + }, + "status": 0, + "tests": "No", + "sso": "N/A" + }, + "icon": "", + "name": "onion", + "repository": "https://git.coopcloud.tech/coop-cloud/onion.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/onion.git", + "versions": [ + { + "1.0.0+alpine": { + "app": { + "image": "tpo/onion-services/onimages/tor", + "tag": "alpine" + } + } + }, + { + "1.0.1+alpine": { + "app": { + "image": "tpo/onion-services/onimages/tor", + "tag": "alpine" + } + } + } + ], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "", + "features": { + "backups": "Yes", + "email": "Yes", + "healthcheck": "3", + "image": { + "image": "vaultwarden/server", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/vaultwarden/server" + }, + "status": 2, + "tests": "No", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/379-697fbe9f0f9481f250fde86e811c1e34", + "name": "vaultwarden", + "repository": "https://git.coopcloud.tech/coop-cloud/vaultwarden.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/vaultwarden.git", + "versions": [ + { + "0.1.0+1.25.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.25.0" + } + } + }, + { + "0.1.1+1.26.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.26.0" + } + } + }, + { + "0.2.0+1.26.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.26.0" + } + } + }, + { + "0.3.0+1.26.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.26.0" + } + } + }, + { + "0.4.0+1.28.1": { + "app": { + "image": "vaultwarden/server", + "tag": "1.28.1" + } + } + }, + { + "0.5.0+1.29.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.29.0" + } + } + }, + { + "0.5.1+1.29.1": { + "app": { + "image": "vaultwarden/server", + "tag": "1.29.1" + } + } + }, + { + "0.6.0+1.29.2": { + "app": { + "image": "vaultwarden/server", + "tag": "1.29.2" + } + } + }, + { + "0.7.0+1.30.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.30.0" + } + } + }, + { + "0.7.1+1.30.1": { + "app": { + "image": "vaultwarden/server", + "tag": "1.30.1" + } + } + }, + { + "0.7.2+1.30.3": { + "app": { + "image": "vaultwarden/server", + "tag": "1.30.3" + } + } + }, + { + "0.8.0+1.31.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.31.0" + } + } + }, + { + "0.9.0+1.32.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.0" + } + } + }, + { + "0.9.1+1.32.3": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.3" + } + } + }, + { + "1.0.0+1.32.3": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.3" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.0.1+1.32.5": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.5" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.0.2+1.32.5": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.5" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.0.3+1.32.5": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.5" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.0.4+1.32.7": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.7" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.1.0+1.33.2": { + "app": { + "image": "vaultwarden/server", + "tag": "1.33.2" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "2.0.0+1.33.2": { + "app": { + "image": "vaultwarden/server", + "tag": "1.33.2" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "2.1.0+1.34.1": { + "app": { + "image": "vaultwarden/server", + "tag": "1.34.1" + }, + "db": { + "image": "mariadb", + "tag": "10.11" + } + } + }, + { + "2.1.1+1.34.3": { + "app": { + "image": "vaultwarden/server", + "tag": "1.34.3" + }, + "db": { + "image": "mariadb", + "tag": "10.11" + } + } + }, + { + "2.1.2+1.35.3": { + "app": { + "image": "vaultwarden/server", + "tag": "1.35.3" + }, + "db": { + "image": "mariadb", + "tag": "10.11" + } + } + }, + { + "2.1.3+1.35.4": { + "app": { + "image": "vaultwarden/server", + "tag": "1.35.4" + }, + "db": { + "image": "mariadb", + "tag": "10.11" + } + } + } + ], + "website": "" + }, + { + "category": "Utilities", + "default_branch": "main", + "description": "Easily and securely send things from one computer to another", + "features": { + "backups": "No", + "email": "No", + "healthcheck": "No", + "image": { + "image": "croc", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/r/schollz/croc" + }, + "status": 3, + "tests": "No", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/365-d44b58c8c77808fb82c2d9fdfee17800", + "name": "croc", + "repository": "https://git.coopcloud.tech/coop-cloud/croc.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/croc.git", + "versions": [], + "website": "https://github.com/schollz/croc" + }, + { + "category": "Utilities", + "default_branch": "main", + "description": "Community Keycloak SSO user management.", + "features": { + "backups": "No", + "email": "N/A", + "healthcheck": "Yes", + "image": { + "image": "", + "rating": "", + "source": "", + "url": "" + }, + "status": 1, + "tests": "Yes", + "sso": "N/A" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/281-ae6d28ba5409a428cbb3a49d5ef6d739", + "name": "keycloak-collective-portal", + "repository": "https://git.coopcloud.tech/coop-cloud/keycloak-collective-portal.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/keycloak-collective-portal.git", + "versions": [], + "website": "" + }, + { + "category": "Utilities", + "default_branch": "main", + "description": "Manage a node in a local resilient mesh, see https://lores.tech/", + "features": { + "backups": "No", + "email": "N/A", + "healthcheck": "No", + "image": { + "image": "lores-node", + "rating": "4", + "source": "upstream", + "url": "https://ghcr.io/local-resilience-tech/lores-node:latest" + }, + "status": 1, + "tests": "0", + "sso": "N/A" + }, + "icon": "", + "name": "lores-node", + "repository": "https://git.coopcloud.tech/coop-cloud/lores-node.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/lores-node.git", + "versions": [ + { + "0.1.0+0.15.3": { + "app": { + "image": "local-resilience-tech/lores-node", + "tag": "v0.15.3" + } + } + }, + { + "0.2.0+v0.16.0": { + "app": { + "image": "local-resilience-tech/lores-node", + "tag": "v0.16.0" + } + } + }, + { + "0.2.1+v0.16.1": { + "app": { + "image": "local-resilience-tech/lores-node", + "tag": "v0.16.1" + } + } + }, + { + "0.2.2+v0.16.2": { + "app": { + "image": "local-resilience-tech/lores-node", + "tag": "v0.16.2" + } + } + } + ], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "", + "features": { + "backups": "Yes", + "email": "No", + "healthcheck": "", + "image": { + "image": "plausible/analytics", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/plausible/analytics" + }, + "status": 1, + "tests": "", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/ba858ce9af5a5e69074b125fd4f99ac7b43fd1ad4fb82d926541175826037921", + "name": "plausible", + "repository": "https://git.coopcloud.tech/coop-cloud/plausible.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/plausible.git", + "versions": [ + { + "1.1.0+v1.5.1": { + "app": { + "image": "plausible/analytics", + "tag": "v1.5.1" + }, + "db": { + "image": "postgres", + "tag": "13.11-alpine" + }, + "plausible_events_db": { + "image": "clickhouse/clickhouse-server", + "tag": "23.4.2.11-alpine" + } + } + }, + { + "2.0.0+v1.5.1": { + "app": { + "image": "plausible/analytics", + "tag": "v1.5.1" + }, + "db": { + "image": "postgres", + "tag": "13.11" + }, + "plausible_events_db": { + "image": "clickhouse/clickhouse-server", + "tag": "23.4.2.11-alpine" + } + } + }, + { + "3.0.0+v2.0.0": { + "app": { + "image": "plausible/analytics", + "tag": "v2.0.0" + }, + "db": { + "image": "postgres", + "tag": "13.12" + }, + "plausible_events_db": { + "image": "clickhouse/clickhouse-server", + "tag": "23.4.2.11-alpine" + } + } + }, + { + "3.0.1+v2.0.0": { + "app": { + "image": "plausible/analytics", + "tag": "v2.0.0" + }, + "db": { + "image": "postgres", + "tag": "13.12" + }, + "plausible_events_db": { + "image": "clickhouse/clickhouse-server", + "tag": "23.4.2.11-alpine" + } + } + } + ], + "website": "https://github.com/plausible/analytics/" + }, + { + "category": "Utilities", + "default_branch": "main", + "description": "RSS feed for Docker Hub images", + "features": { + "backups": "?", + "email": "?", + "healthcheck": "?", + "image": { + "image": "docker-hub-rss", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/r/theconnman/docker-hub-rss/" + }, + "status": 0, + "tests": "?", + "sso": "?" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/210-09cd28347983de7287a1af0792371087", + "name": "docker-hub-rss", + "repository": "https://git.coopcloud.tech/coop-cloud/docker-hub-rss.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/docker-hub-rss.git", + "versions": [ + { + "0.1.0+0.4.2": { + "app": { + "image": "theconnman/docker-hub-rss", + "tag": "0.4.2" + } + } + } + ], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "A web-based farm record keeping application. ", + "features": { + "backups": "No", + "email": "No", + "healthcheck": "No", + "image": { + "image": "farmos", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/r/farmos" + }, + "status": 0, + "tests": "No", + "sso": "No" + }, + "icon": "", + "name": "farmos", + "repository": "https://git.coopcloud.tech/coop-cloud/farmos.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/farmos.git", + "versions": [], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "Funkwhale is a self-hosted, modern free and open-source music server, heavily inspired by Grooveshark.", + "features": { + "backups": "No", + "email": "No", + "healthcheck": "No", + "image": { + "image": "funkwhale", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/r/funkwhale" + }, + "status": 0, + "tests": "No", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/455-5c7ee2a7d5ca18dbd1fba168994f9196", + "name": "funkwhale", + "repository": "https://git.coopcloud.tech/coop-cloud/funkwhale.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/funkwhale.git", + "versions": [ + { + "0.1.0+1.25.3": { + "api": { + "image": "funkwhale/funkwhale", + "tag": "1.2" + }, + "app": { + "image": "nginx", + "tag": "1.25.3" + }, + "cache": { + "image": "redis", + "tag": "7-alpine" + }, + "celerybeat": { + "image": "funkwhale/funkwhale", + "tag": "1.2" + }, + "celeryworker": { + "image": "funkwhale/funkwhale", + "tag": "1.2" + }, + "db": { + "image": "postgres", + "tag": "10-alpine" + } + } + }, + { + "0.1.1+1.27.1": { + "api": { + "image": "funkwhale/funkwhale", + "tag": "1.2" + }, + "app": { + "image": "nginx", + "tag": "1.27.1" + }, + "cache": { + "image": "redis", + "tag": "7-alpine" + }, + "celerybeat": { + "image": "funkwhale/funkwhale", + "tag": "1.2" + }, + "celeryworker": { + "image": "funkwhale/funkwhale", + "tag": "1.2" + }, + "db": { + "image": "postgres", + "tag": "10-alpine" + } + } + } + ], + "website": "" + } +] \ No newline at end of file diff --git a/src/services/mock-logs.json b/src/services/mock-logs.json new file mode 100644 index 0000000..5c19923 --- /dev/null +++ b/src/services/mock-logs.json @@ -0,0 +1,326 @@ +{ + "deploy": { + "logs": [ + { + "type": "info", + "text": "📦 Deploying {appName}..." + }, + { + "type": "command", + "text": "$ abra app deploy {appName}" + }, + { + "type": "info", + "text": "Checking server connection..." + }, + { + "type": "success", + "text": "✓ Connected to server" + }, + { + "type": "info", + "text": "Pulling latest images..." + }, + { + "type": "output", + "text": "latest: Pulling from library/traefik" + }, + { + "type": "output", + "text": "a1b2c3d4: Pull complete" + }, + { + "type": "output", + "text": "e5f6g7h8: Pull complete" + }, + { + "type": "success", + "text": "✓ Images pulled successfully" + }, + { + "type": "info", + "text": "Creating containers..." + }, + { + "type": "output", + "text": "Creating {appName}_app_1 ... done" + }, + { + "type": "output", + "text": "Creating {appName}_db_1 ... done" + }, + { + "type": "info", + "text": "Starting services..." + }, + { + "type": "output", + "text": "Starting {appName}_app_1 ... done" + }, + { + "type": "output", + "text": "Starting {appName}_db_1 ... done" + }, + { + "type": "success", + "text": "✓ {appName} deployed successfully!" + } + ] + }, + "stop": { + "logs": [ + { + "type": "info", + "text": "🛑 Stopping {appName}..." + }, + { + "type": "command", + "text": "$ abra app stop {appName}" + }, + { + "type": "output", + "text": "Stopping {appName}_app_1 ... done" + }, + { + "type": "output", + "text": "Stopping {appName}_db_1 ... done" + }, + { + "type": "success", + "text": "✓ {appName} stopped" + } + ] + }, + "start": { + "logs": [ + { + "type": "info", + "text": "▶️ Starting {appName}..." + }, + { + "type": "command", + "text": "$ abra app start {appName}" + }, + { + "type": "output", + "text": "Starting {appName}_app_1 ... done" + }, + { + "type": "output", + "text": "Starting {appName}_db_1 ... done" + }, + { + "type": "success", + "text": "✓ {appName} started" + } + ] + }, + "upgrade": { + "logs": [ + { + "type": "info", + "text": "Upgrading {appName} to {version}..." + }, + { + "type": "command", + "text": "$ abra app upgrade {appName}" + }, + { + "type": "info", + "text": "Pulling new version..." + }, + { + "type": "output", + "text": "{version}: Pulling from library/app" + }, + { + "type": "output", + "text": "x9y8z7w6: Pull complete" + }, + { + "type": "success", + "text": "✓ New version pulled" + }, + { + "type": "info", + "text": "Stopping old containers..." + }, + { + "type": "output", + "text": "Stopping {appName}_app_1 ... done" + }, + { + "type": "info", + "text": "Starting new containers..." + }, + { + "type": "output", + "text": "Starting {appName}_app_1 ... done" + }, + { + "type": "success", + "text": "✓ {appName} upgraded to {version}" + } + ] + }, + "remove": { + "logs": [ + { + "type": "warning", + "text": "⚠️ Removing {appName}..." + }, + { + "type": "command", + "text": "$ abra app remove {appName}" + }, + { + "type": "output", + "text": "Stopping {appName}_app_1 ... done" + }, + { + "type": "output", + "text": "Stopping {appName}_db_1 ... done" + }, + { + "type": "output", + "text": "Removing {appName}_app_1 ... done" + }, + { + "type": "output", + "text": "Removing {appName}_db_1 ... done" + }, + { + "type": "output", + "text": "Removing network {appName}_default" + }, + { + "type": "success", + "text": "✓ {appName} removed" + } + ] + }, + "serverRefresh": { + "logs": [ + { + "type": "info", + "text": "🔄 Refreshing server {serverName}..." + }, + { + "type": "command", + "text": "$ abra server ls" + }, + { + "type": "info", + "text": "Fetching server status..." + }, + { + "type": "output", + "text": "Checking connection..." + }, + { + "type": "success", + "text": "✓ Connected" + }, + { + "type": "output", + "text": "Fetching app list..." + }, + { + "type": "success", + "text": "✓ Found apps on {serverName}" + }, + { + "type": "success", + "text": "✓ Server refreshed" + } + ] + }, + "deployAll": { + "logs": [ + { + "type": "info", + "text": "📦 Deploying all apps on {serverName}..." + }, + { + "type": "command", + "text": "$ abra app deploy --all --server {serverName}" + }, + { + "type": "info", + "text": "Found {appCount} apps to deploy" + }, + { + "type": "output", + "text": "Deploying apps in parallel..." + }, + { + "type": "info", + "text": "[1/{appCount}] Deploying app_1..." + }, + { + "type": "success", + "text": "✓ app_1 deployed" + }, + { + "type": "info", + "text": "[2/{appCount}] Deploying app_2..." + }, + { + "type": "success", + "text": "✓ app_2 deployed" + }, + { + "type": "info", + "text": "[3/{appCount}] Deploying app_3..." + }, + { + "type": "success", + "text": "✓ app_3 deployed" + }, + { + "type": "success", + "text": "✓ All apps deployed on {serverName}" + } + ] + }, + "upgradeAll": { + "logs": [ + { + "type": "info", + "text": "Upgrading all apps on {serverName}..." + }, + { + "type": "command", + "text": "$ abra app upgrade --all --server {serverName}" + }, + { + "type": "info", + "text": "Found {upgradeCount} apps with available upgrades" + }, + { + "type": "output", + "text": "Upgrading apps sequentially..." + }, + { + "type": "info", + "text": "[1/{upgradeCount}] Upgrading app_1..." + }, + { + "type": "success", + "text": "✓ app_1 upgraded" + }, + { + "type": "info", + "text": "[2/{upgradeCount}] Upgrading app_2..." + }, + { + "type": "success", + "text": "✓ app_2 upgraded" + }, + { + "type": "success", + "text": "✓ All apps upgraded on {serverName}" + } + ] + } +} \ No newline at end of file diff --git a/src/services/mockApi.ts b/src/services/mockApi.ts index 7e52479..71b776b 100644 --- a/src/services/mockApi.ts +++ b/src/services/mockApi.ts @@ -1,10 +1,69 @@ -import type { AbraServer, ServerAppsResponse } from '../types'; +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'; + text: string; + timestamp: Date; +}; + +// Type for the imported JSON structure +type LogTemplate = { + type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output'; + text: string; +}; + +type LogsDataStructure = { + [key: string]: { + logs: LogTemplate[]; + }; +}; // Simulate API delay const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +// Helper to replace template variables like {appName}, {version} +const replaceVars = (text: string, vars: Record): string => { + let result = text; + Object.entries(vars).forEach(([key, value]) => { + result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value)); + }); + return result; +}; + +// Helper to process log templates into LogEntry objects +const processLogs = ( + action: string, + vars: Record +): LogEntry[] => { + console.log('Processing logs for action:', action); + console.log('Loaded logsData:', logsData); + + const typedLogsData = logsData as LogsDataStructure; + const actionData = typedLogsData[action]; + + if (!actionData || !actionData.logs) { + console.error(`No logs found for action: ${action}`); + return []; + } + + console.log('Found logs:', actionData.logs); + + const templates = actionData.logs; + return templates + .filter(template => template && template.type && template.text) // Filter out any undefined/malformed entries + .map(template => ({ + type: template.type, + text: replaceVars(template.text, vars), + timestamp: new Date(), + })); +}; + export const mockApiService = { async getAppsGrouped(): Promise { await delay(500); @@ -16,18 +75,71 @@ export const mockApiService = { return serversData as AbraServer[]; }, - async deployApp(appName: string): Promise { - await delay(1000); - console.log(`Mock: Deploying app ${appName}`); + async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const logs = processLogs('deploy', { appName }); + + for (const log of logs) { + await delay(200); + onLog?.(log); + } }, - async stopApp(appName: string): Promise { - await delay(500); - console.log(`Mock: Stopping app ${appName}`); + async stopApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const logs = processLogs('stop', { appName }); + + for (const log of logs) { + await delay(150); + onLog?.(log); + } }, - async startApp(appName: string): Promise { - await delay(500); - console.log(`Mock: Starting app ${appName}`); + async upgradeApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise { + 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 { + const logs = processLogs('remove', { appName }); + + for (const log of logs) { + await delay(180); + onLog?.(log); + } + }, + + async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise { + 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 { + 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 { + const logs = processLogs('upgradeAll', { serverName, upgradeCount }); + + for (const log of logs) { + await delay(250); + onLog?.(log); + } + }, + // recipe catalog imports + async getRecipes(): Promise { + await delay(300); + return catalogue as AbraRecipe[]; + } }; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index a3ab2ac..8e2727f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,19 +1,3 @@ -export interface User { - id: string; - username: string; - email: string; -} - -export interface AuthResponse { - user: User; - token: string; -} - -export interface LoginCredentials { - username: string; - password: string; -} - export interface ApiError { message: string; status: number; @@ -52,4 +36,36 @@ export interface AppWithServer extends AbraApp { appCount: number; upgradeCount: number; }; +} +export interface Image { + image: string; + rating: string + source: string + url: string +} +type RecipeVersions = Record>[]; +export interface ServiceMeta { + image: string; + tag: string +} +export interface Features { + backups: string; + email: string; + healthcheck: string; + image: Image; + status: number; + tests: string; + sso: string; +} +export interface AbraRecipe { + category: string; + default_branch: string; + description: string; + features: Features; + icon: string; + name: string; + repository: string; + ssh_url: string; + versions: RecipeVersions; + website: string; } \ No newline at end of file