├── .github └── funding.yml ├── commitlint.config.js ├── server ├── backend │ ├── services │ │ └── scripts │ │ │ ├── data_package_config │ │ │ ├── modules │ │ │ │ ├── __init__.py │ │ │ │ ├── custom_files.py │ │ │ │ ├── package_creator.py │ │ │ │ └── certificates.py │ │ │ ├── models.py │ │ │ ├── data_package_manager.py │ │ │ └── data_package.py │ │ │ ├── docker │ │ │ └── docker_manager.py │ │ │ └── takserver │ │ │ └── fix_database.py │ ├── config │ │ └── logging_config.py │ ├── routes │ │ ├── dashboard_routes.py │ │ ├── port_manager_routes.py │ │ ├── docker_manager_routes.py │ │ ├── data_package_manager_routes.py │ │ └── takserver_api_routes.py │ └── __init__.py ├── pyproject.toml └── app.py ├── client ├── public │ ├── favicon.ico │ ├── pwa-64x64.png │ ├── pwa-192x192.png │ ├── pwa-512x512.png │ ├── maskable-icon-512x512.png │ └── apple-touch-icon-180x180.png ├── src │ ├── lib │ │ └── utils.ts │ ├── components │ │ ├── shared │ │ │ ├── ui │ │ │ │ ├── icons │ │ │ │ │ ├── DeleteIcon.jsx │ │ │ │ │ ├── UploadIcon.jsx │ │ │ │ │ ├── LoadingSpinner.tsx │ │ │ │ │ └── CloseIcon.jsx │ │ │ │ ├── shadcn │ │ │ │ │ ├── skeleton.tsx │ │ │ │ │ ├── mode-toggle.tsx │ │ │ │ │ ├── label.tsx │ │ │ │ │ ├── separator.tsx │ │ │ │ │ ├── toast │ │ │ │ │ │ └── toaster.tsx │ │ │ │ │ ├── checkbox.tsx │ │ │ │ │ ├── switch.tsx │ │ │ │ │ ├── badge.tsx │ │ │ │ │ ├── popover.tsx │ │ │ │ │ ├── tooltip │ │ │ │ │ │ ├── tooltip.tsx │ │ │ │ │ │ └── HelpIconTooltip.tsx │ │ │ │ │ ├── progress.tsx │ │ │ │ │ ├── tabs.tsx │ │ │ │ │ ├── card │ │ │ │ │ │ └── card.tsx │ │ │ │ │ ├── table.tsx │ │ │ │ │ ├── combobox.tsx │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ ├── scroll-area.tsx │ │ │ │ │ ├── theme-provider.tsx │ │ │ │ │ ├── sheet.tsx │ │ │ │ │ ├── form.tsx │ │ │ │ │ └── charts │ │ │ │ │ │ └── AnalyticsChart.tsx │ │ │ │ ├── layout │ │ │ │ │ └── Layout.tsx │ │ │ │ └── inputs │ │ │ │ │ └── ContainerStartStopButton.tsx │ │ │ ├── hooks │ │ │ │ ├── use-mobile.tsx │ │ │ │ ├── use-mobile.ts │ │ │ │ └── useTakServerRequired.tsx │ │ │ └── TakServerRequiredDialog.tsx │ │ ├── takserver │ │ │ ├── Configuration.tsx │ │ │ ├── TakServerStatus.tsx │ │ │ ├── components │ │ │ │ └── TakServerStatus │ │ │ │ │ └── StatusDisplay.tsx │ │ │ └── AdvancedFeatures.tsx │ │ ├── transfer │ │ │ ├── TransferLog │ │ │ │ └── index.jsx │ │ │ ├── TransferStatus │ │ │ │ ├── StatusButtons.jsx │ │ │ │ ├── index.jsx │ │ │ │ └── DeviceProgress.jsx │ │ │ └── FileUpload │ │ │ │ ├── index.jsx │ │ │ │ └── FileList.jsx │ │ ├── datapackage │ │ │ ├── AtakPreferencesSection │ │ │ │ ├── AtakPreferencesNav.tsx │ │ │ │ └── atakConfirmDefaults.tsx │ │ │ ├── UploadCustomFilesSection │ │ │ │ └── FileUploadProgress.tsx │ │ │ ├── ExistingDataPackages │ │ │ │ └── PackageOperationPopups.tsx │ │ │ ├── shared │ │ │ │ ├── PreferenceItem.tsx │ │ │ │ └── validationSchemas.ts │ │ │ └── CotStreamsSection │ │ │ │ └── cotStreamConfig.ts │ │ └── certmanager │ │ │ └── CertificateOperationPopups.tsx │ ├── types │ │ └── global.d.ts │ ├── vite-env.d.ts │ ├── pages │ │ ├── Takserver.tsx │ │ ├── CertManager.tsx │ │ └── AdvancedFeatures.tsx │ ├── App.tsx │ ├── utils │ │ └── uploadProgress.ts │ └── index.tsx ├── tsconfig.json ├── index.html ├── package.json └── vite.config.ts ├── .gitmodules ├── .env.example ├── .gitignore ├── package.json ├── pyproject.toml ├── docker-compose.yml ├── Dockerfile ├── README.md ├── README.DEV.md └── cliff.toml /.github/funding.yml: -------------------------------------------------------------------------------- 1 | ko_fi: jakeolsen -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']}; 2 | -------------------------------------------------------------------------------- /server/backend/services/scripts/data_package_config/modules/__init__.py: -------------------------------------------------------------------------------- 1 | # Package initialization file -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JShadowNull/TAK-Manager/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JShadowNull/TAK-Manager/HEAD/client/public/pwa-64x64.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "TAK-Wrapper"] 2 | path = TAK-Wrapper 3 | url = https://github.com/JShadowNull/TAK-Wrapper.git -------------------------------------------------------------------------------- /client/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JShadowNull/TAK-Manager/HEAD/client/public/pwa-192x192.png -------------------------------------------------------------------------------- /client/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JShadowNull/TAK-Manager/HEAD/client/public/pwa-512x512.png -------------------------------------------------------------------------------- /client/public/maskable-icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JShadowNull/TAK-Manager/HEAD/client/public/maskable-icon-512x512.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JShadowNull/TAK-Manager/HEAD/client/public/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /client/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/icons/DeleteIcon.jsx: -------------------------------------------------------------------------------- 1 | export const DeleteIcon = () => ( 2 | 3 | 4 | 5 | ); -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /client/src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | pywebview?: { 3 | api: { 4 | save_file_dialog: ( 5 | filename: string, 6 | fileTypes: Array<[string, string | string[]]> 7 | ) => Promise; 8 | write_binary_file: (path: string, data: Uint8Array) => Promise; 9 | } 10 | }; 11 | handleNativeFileDrop: (path: string) => void; // Added from file_context_0 12 | } 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Application Mode (development/production) 2 | MODE=production 3 | 4 | # Server Ports 5 | 6 | BACKEND_PORT= 7 | # Frontend Port not used in production 8 | FRONTEND_PORT= 5173 9 | 10 | # TAK Server Installation Directory 11 | TAK_SERVER_INSTALL_DIR= 12 | 13 | # Docker Configuration 14 | RESTART_POLICY=unless-stopped 15 | 16 | # Health Check Configuration 17 | HEALTHCHECK_INTERVAL=30s 18 | HEALTHCHECK_TIMEOUT=10s 19 | HEALTHCHECK_RETRIES=3 -------------------------------------------------------------------------------- /client/src/components/shared/ui/icons/UploadIcon.jsx: -------------------------------------------------------------------------------- 1 | export const UploadIcon = () => ( 2 | 3 | 8 | 9 | ); -------------------------------------------------------------------------------- /server/backend/services/scripts/data_package_config/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import Dict, Any, List 3 | 4 | class TakServerConfig(BaseModel): 5 | count: str 6 | description0: str 7 | ipAddress0: str 8 | port0: str 9 | protocol0: str 10 | caLocation0: str 11 | certPassword0: str 12 | 13 | class DataPackageRequest(BaseModel): 14 | takServerConfig: TakServerConfig 15 | atakPreferences: Dict[str, Any] 16 | clientCert: str 17 | zipFileName: str 18 | customFiles: List[str] # List of filenames to include -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_APP_TITLE: string 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv 9 | } 10 | 11 | declare module 'virtual:pwa-register' { 12 | export interface RegisterSWOptions { 13 | immediate?: boolean 14 | onNeedRefresh?: () => void 15 | onOfflineReady?: () => void 16 | onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void 17 | onRegisterError?: (error: any) => void 18 | } 19 | 20 | export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise 21 | } -------------------------------------------------------------------------------- /client/src/components/shared/hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/icons/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cn } from '../../../../lib/utils'; 3 | 4 | interface LoadingSpinnerProps { 5 | className?: string; 6 | } 7 | 8 | export const LoadingSpinner: React.FC = ({ className }) => ( 9 | 10 | 11 | 12 | 13 | ); -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from "lucide-react" 2 | import { Switch } from "./switch" 3 | import { useTheme } from "./theme-provider" 4 | 5 | export function ModeToggle() { 6 | const { theme, setTheme } = useTheme() 7 | 8 | return ( 9 |
10 | 11 | setTheme(checked ? "dark" : "light")} 14 | /> 15 | 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "noEmit": true, 12 | "jsx": "react-jsx", 13 | "strict": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | }, 21 | "allowJs": true, 22 | "esModuleInterop": true, 23 | "allowSyntheticDefaultImports": true, 24 | "forceConsistentCasingInFileNames": true 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/icons/CloseIcon.jsx: -------------------------------------------------------------------------------- 1 | import CancelIcon from '@mui/icons-material/Cancel'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export const CloseIcon = ({ 5 | color = 'currentColor', 6 | className = '', 7 | size = 'small', 8 | onClick 9 | }) => ( 10 |
11 | 24 |
25 | ); 26 | 27 | CloseIcon.propTypes = { 28 | color: PropTypes.string, 29 | className: PropTypes.string, 30 | size: PropTypes.oneOf(['small', 'medium', 'large']), 31 | onClick: PropTypes.func 32 | }; 33 | -------------------------------------------------------------------------------- /server/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "tak-manager-server" 3 | version = "3.1.2" 4 | description = "Backend for TAK Manager" 5 | authors = [ 6 | {name = "JShadowNull",email = "95039572+JShadowNull@users.noreply.github.com"} 7 | ] 8 | requires-python = ">=3.13" 9 | dependencies = [ 10 | "fastapi (>=0.115.12,<0.116.0)", 11 | "uvicorn (>=0.34.0,<1.0.0)", 12 | "requests (>=2.32.3,<3.0.0)", 13 | "psutil (>=7.0.0,<8.0.0)", 14 | "docker (>=7.1.0,<8.0.0)", 15 | "pexpect (>=4.9.0,<5.0.0)", 16 | "watchdog (>=6.0.0,<7.0.0)", 17 | "sse-starlette (>=2.2.1,<3.0.0)", 18 | "python-multipart (>=0.0.20,<0.0.21)", 19 | "lxml (>=5.3.1,<6.0.0)", 20 | "aiofiles (>=24.1.0,<25.0.0)", 21 | "pyyaml (>=6.0.2,<7.0.0)" 22 | ] 23 | 24 | 25 | [build-system] 26 | requires = ["poetry-core>=2.0.0,<3.0.0"] 27 | build-backend = "poetry.core.masonry.api" 28 | 29 | [tool.poetry] 30 | package-mode = false 31 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Separator = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >( 10 | ( 11 | { className, orientation = "horizontal", decorative = true, ...props }, 12 | ref 13 | ) => ( 14 | 25 | ) 26 | ) 27 | Separator.displayName = SeparatorPrimitive.Root.displayName 28 | 29 | export { Separator } 30 | -------------------------------------------------------------------------------- /client/src/components/shared/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | // Default breakpoint for mobile devices (768px) 4 | const MOBILE_BREAKPOINT = 768 5 | 6 | export function useIsMobile() { 7 | const [isMobile, setIsMobile] = useState( 8 | typeof window !== "undefined" ? window.innerWidth < MOBILE_BREAKPOINT : false 9 | ) 10 | 11 | useEffect(() => { 12 | if (typeof window === "undefined") return 13 | 14 | const handleResize = () => { 15 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 16 | } 17 | 18 | // Add event listener 19 | window.addEventListener("resize", handleResize) 20 | 21 | // Call handler right away so state gets updated with initial window size 22 | handleResize() 23 | 24 | // Remove event listener on cleanup 25 | return () => window.removeEventListener("resize", handleResize) 26 | }, []) 27 | 28 | return isMobile 29 | } -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/toast/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | Toast, 5 | ToastClose, 6 | ToastDescription, 7 | ToastProvider, 8 | ToastTitle, 9 | ToastViewport, 10 | } from "./toast" 11 | import { useToast } from "./use-toast" 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast() 15 | 16 | return ( 17 | 18 | {toasts.map(({ id, title, description, action, ...props }) => { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ) 31 | })} 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Outlet } from 'react-router-dom'; 3 | import { AppSidebar } from '../shadcn/sidebar/app-sidebar'; 4 | import { SidebarProvider } from '../shadcn/sidebar/sidebar'; 5 | import { ScrollArea } from '@/components/shared/ui/shadcn/scroll-area'; 6 | import { Toaster } from "@/components/shared/ui/shadcn/toast/toaster" 7 | 8 | const Layout: React.FC = () => { 9 | return ( 10 | 11 |
12 | 13 |
14 |
15 | 16 |
17 | 18 |
19 |
20 |
21 |
22 |
23 | 24 |
25 | ); 26 | }; 27 | 28 | export default Layout; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Start of Selection 2 | # Python virtual environment 3 | .venv/ 4 | venv/ 5 | 6 | # Distribution directories 7 | dist/ 8 | # Added to ignore distribution directories 9 | dist-ssr/ 10 | 11 | # Python cache files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # Node modules 17 | node_modules/ 18 | 19 | # Environment variables 20 | .env 21 | .env.dev 22 | !.env.example 23 | 24 | # IDE files 25 | .vscode/ 26 | .idea/ 27 | *.swp 28 | *.swo 29 | 30 | # OS files 31 | .DS_Store 32 | Thumbs.db 33 | 34 | # Server logs 35 | logs/ 36 | app.log 37 | 38 | # Dependencies 39 | node_modules/ 40 | npm-debug.log* 41 | yarn-debug.log* 42 | yarn-error.log* 43 | 44 | # Build output 45 | dist/ 46 | release/ 47 | *.app 48 | *.exe 49 | *.dmg 50 | 51 | # Docker data 52 | docker/data/ 53 | 54 | client/build/ 55 | client/dist/ 56 | client/dev-dist/ 57 | 58 | commands.txt 59 | # End of Selection 60 | docker/tak-manager-1.0.0.tar.gz 61 | 62 | # internal docs 63 | docs/ 64 | 65 | # ignore cursor rules 66 | .cursor 67 | 68 | package.json.bak 69 | .env.bk.example 70 | scripts/ 71 | .env.github 72 | -------------------------------------------------------------------------------- /client/src/pages/Takserver.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TakServerStatus from '../components/takserver/TakServerStatus'; 3 | import Configuration from '../components/takserver/Configuration'; 4 | import AdvancedFeatures from '../components/takserver/AdvancedFeatures'; 5 | import { useTakServer } from '../components/shared/ui/shadcn/sidebar/app-sidebar'; 6 | 7 | export const Takserver: React.FC = () => { 8 | const { serverState } = useTakServer(); 9 | 10 | // Show nothing while we wait for initial state 11 | if (!serverState) { 12 | return null; 13 | } 14 | 15 | // Show only TakServerStatus when not installed 16 | if (!serverState.isInstalled) { 17 | return ( 18 |
19 | 20 |
21 | ); 22 | } 23 | 24 | // Show full UI when installed 25 | return ( 26 |
27 | 28 | 29 | 30 |
31 | ); 32 | }; 33 | 34 | export default Takserver; 35 | -------------------------------------------------------------------------------- /client/src/components/takserver/Configuration.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Button } from '../shared/ui/shadcn/button'; 4 | 5 | const Configuration: React.FC = () => { 6 | const navigate = useNavigate(); 7 | 8 | return ( 9 |
10 |
11 |

Configuration

12 |
13 | 20 | 27 |
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default Configuration; -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { Check } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 22 | 23 | 24 | 25 | )) 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 27 | 28 | export { Checkbox } 29 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SwitchPrimitives from "@radix-ui/react-switch" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )) 25 | Switch.displayName = SwitchPrimitives.Root.displayName 26 | 27 | export { Switch } 28 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Routes, Route } from "react-router-dom" 2 | import { ThemeProvider } from "@/components/shared/ui/shadcn/theme-provider" 3 | import { TakServerProvider } from "@/components/shared/ui/shadcn/sidebar/app-sidebar" 4 | import Layout from './components/shared/ui/layout/Layout' 5 | import Dashboard from './pages/Dashboard' 6 | import Takserver from './pages/Takserver' 7 | import DataPackage from './pages/DataPackage' 8 | import CertManager from './pages/CertManager' 9 | import AdvancedFeatures from './pages/AdvancedFeatures' 10 | 11 | function App() { 12 | return ( 13 | 14 | 15 | 16 | 17 | }> 18 | } /> 19 | } /> 20 | } /> 21 | } /> 22 | } /> 23 | 24 | 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | export default App -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as PopoverPrimitive from "@radix-ui/react-popover" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Popover = PopoverPrimitive.Root 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger 9 | 10 | const PopoverContent = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 14 | 15 | 25 | 26 | )) 27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 28 | 29 | export { Popover, PopoverTrigger, PopoverContent } 30 | -------------------------------------------------------------------------------- /client/src/components/transfer/TransferLog/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ScrollArea } from '../../shared/ui/shadcn/scroll-area'; 3 | 4 | export function TransferLog({ logs = [] }) { 5 | const getLogStyle = (log) => { 6 | const message = log.message || ''; 7 | 8 | if (message.includes('Error:')) { 9 | return 'text-red-500'; 10 | } 11 | if (message.includes('Device connected')) { 12 | return 'text-green-500'; 13 | } 14 | if (message.includes('Device disconnected')) { 15 | return 'text-yellow-500'; 16 | } 17 | return 'text-gray-500'; 18 | }; 19 | 20 | return ( 21 |
22 |

Transfer Log

23 | 24 |
25 | {logs.map((log, index) => ( 26 |
27 | 28 | {new Date(log.timestamp).toLocaleTimeString()} -{' '} 29 | 30 | {log.message} 31 |
32 | ))} 33 | {logs.length === 0 && ( 34 |
No logs yet...
35 | )} 36 |
37 |
38 |
39 | ); 40 | } -------------------------------------------------------------------------------- /client/src/components/takserver/TakServerStatus.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import StatusDisplay from './components/TakServerStatus/StatusDisplay'; 3 | import ControlButtons from './components/TakServerStatus/ControlButtons'; 4 | import InstallationForm from './components/TakServerStatus/InstallationForm'; 5 | 6 | interface TakServerStatusProps { 7 | serverState: { 8 | isInstalled: boolean; 9 | isRunning: boolean; 10 | version: string; 11 | error?: string; 12 | }; 13 | loading?: boolean; 14 | } 15 | 16 | const TakServerStatus: React.FC = ({ serverState }) => { 17 | // UI state 18 | const [showInstallForm, setShowInstallForm] = useState(false); 19 | 20 | return ( 21 | <> 22 |
23 |
24 |
25 |

TAK Server Status

26 | 27 |
28 | setShowInstallForm(true)} 31 | /> 32 |
33 |
34 | 35 | {showInstallForm && ( 36 | setShowInstallForm(false)} /> 37 | )} 38 | 39 | ); 40 | }; 41 | 42 | export default TakServerStatus; -------------------------------------------------------------------------------- /server/backend/config/logging_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import logging 4 | 5 | def get_default_level(): 6 | """Get the default log level based on mode""" 7 | return logging.DEBUG if os.getenv('MODE') == 'development' else logging.INFO 8 | 9 | def configure_logging(name, level=None): 10 | """Simple logging configuration that outputs to both file and console. 11 | 12 | Args: 13 | name: Logger name (usually __name__ from the calling module) 14 | level: Optional log level to override the default from mode 15 | """ 16 | # Use absolute path for logs in Docker 17 | logs_dir = '/app/logs' 18 | os.makedirs(logs_dir, exist_ok=True) 19 | 20 | # Create logger 21 | logger = logging.getLogger(name) 22 | logger.setLevel(level if level is not None else get_default_level()) 23 | 24 | # Prevent duplicate handlers 25 | if logger.handlers: 26 | return logger 27 | 28 | # Create formatters 29 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 30 | 31 | # File handler 32 | file_handler = logging.FileHandler(os.path.join(logs_dir, 'app.log')) 33 | file_handler.setFormatter(formatter) 34 | logger.addHandler(file_handler) 35 | 36 | # Console handler 37 | console_handler = logging.StreamHandler(sys.stdout) 38 | console_handler.setFormatter(formatter) 39 | logger.addHandler(console_handler) 40 | 41 | return logger -------------------------------------------------------------------------------- /client/src/components/datapackage/AtakPreferencesSection/AtakPreferencesNav.tsx: -------------------------------------------------------------------------------- 1 | import { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger } from "@/components/shared/ui/shadcn/navigation-menu"; 2 | import { PREFERENCE_CATEGORIES } from "./atakPreferencesConfig"; 3 | 4 | interface AtakPreferencesNavProps { 5 | activeCategory: string; 6 | onCategoryChange: (category: string) => void; 7 | categories: typeof PREFERENCE_CATEGORIES; 8 | className?: string; 9 | } 10 | 11 | export const AtakPreferencesNav = ({ 12 | activeCategory, 13 | onCategoryChange, 14 | categories, 15 | className = '' 16 | }: AtakPreferencesNavProps) => { 17 | return ( 18 | 19 | 20 | {Object.entries(categories).map(([key, name]) => ( 21 | 22 | onCategoryChange(key)} 25 | className={`text-sm px-3 py-1 ${ 26 | activeCategory === key 27 | ? 'bg-accent text-accent-foreground' 28 | : 'hover:bg-accent/50' 29 | }`} 30 | > 31 | {name} 32 | 33 | 34 | ))} 35 | 36 | 37 | ); 38 | }; -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/tooltip/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "../../../../../lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | // Add helper type for tooltip placement 11 | export type TooltipSide = 'top' | 'right' | 'bottom' | 'left'; 12 | export type TooltipTriggerMode = 'click' | 'hover'; 13 | 14 | const Tooltip = TooltipPrimitive.Root; 15 | Tooltip.displayName = TooltipPrimitive.Root.displayName 16 | 17 | const TooltipTrigger = TooltipPrimitive.Trigger 18 | 19 | const TooltipContent = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef 22 | >(({ className, sideOffset = 4, ...props }, ref) => ( 23 | 32 | )) 33 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 34 | 35 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 36 | -------------------------------------------------------------------------------- /client/src/components/datapackage/UploadCustomFilesSection/FileUploadProgress.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Progress } from '@/components/shared/ui/shadcn/progress'; 3 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/shared/ui/shadcn/dialog'; 4 | 5 | interface FileUploadProgressProps { 6 | isOpen: boolean; 7 | fileName: string; 8 | progress: number; 9 | } 10 | 11 | const FileUploadProgress: React.FC = ({ 12 | isOpen, 13 | fileName, 14 | progress 15 | }) => { 16 | return ( 17 | 18 | e.preventDefault()} 20 | onEscapeKeyDown={(e) => e.preventDefault()} 21 | className="w-[95%] mx-auto sm:w-full max-w-md" 22 | > 23 | 24 | Uploading File 25 | 26 | {progress < 100 27 | ? "Please wait while your file is being uploaded..." 28 | : "File uploaded"} 29 | 30 | 31 |
32 | 39 |
40 |
41 |
42 | ); 43 | }; 44 | 45 | export default FileUploadProgress; -------------------------------------------------------------------------------- /server/backend/routes/dashboard_routes.py: -------------------------------------------------------------------------------- 1 | # backend/routes/dashboard_routes.py 2 | 3 | from fastapi import APIRouter, HTTPException 4 | from sse_starlette.sse import EventSourceResponse 5 | from ..services.scripts.system.system_monitor import SystemMonitor 6 | from backend.config.logging_config import configure_logging 7 | 8 | logger = configure_logging(__name__) 9 | 10 | dashboard = APIRouter() 11 | 12 | # Global state for SSE events 13 | _latest_metrics: dict = {} 14 | 15 | def emit_metrics_event(data: dict): 16 | """Update latest metrics state""" 17 | global _latest_metrics 18 | _latest_metrics = data 19 | 20 | system_monitor = SystemMonitor(emit_event=emit_metrics_event) 21 | 22 | @dashboard.get('/monitoring/metrics-stream') 23 | async def metrics_stream(): 24 | """SSE endpoint for system metrics.""" 25 | return EventSourceResponse(system_monitor.metrics_generator()) 26 | 27 | @dashboard.post('/monitoring/start') 28 | async def start_all_monitoring(): 29 | """Get current system metrics.""" 30 | try: 31 | metrics = system_monitor.get_system_metrics() 32 | if not metrics: 33 | raise HTTPException(status_code=500, detail="Failed to get system metrics") 34 | return { 35 | "status": True, 36 | "data": metrics 37 | } 38 | except Exception as e: 39 | logger.error(f"Error getting system metrics: {str(e)}") 40 | raise HTTPException( 41 | status_code=500, 42 | detail=f"Failed to get system metrics: {str(e)}" 43 | ) 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tak-manager-root", 3 | "version": "3.1.2", 4 | "private": true, 5 | "workspaces": [ 6 | "client", 7 | "TAK-Wrapper/web" 8 | ], 9 | "scripts": { 10 | "setup": "python dev_scripts/setup_project.py", 11 | "docker:image-mac": "npm run image:mac --workspace=client", 12 | "docker:image-win": "npm run image:win --workspace=client", 13 | "update:version": "node dev_scripts/version/update-version.js", 14 | "dev": "npm run docker:dev --workspace=client", 15 | "wrapper:dev": "cd TAK-Wrapper && poetry run python app.py --dev", 16 | "package:mac": "VERSION=$npm_package_version npm run image:mac --workspace=client && VERSION=$npm_package_version npm run package --workspace=TAK-Wrapper/web && create-dmg --dmg-title=\"TAK Manager $npm_package_version\" 'TAK-Wrapper/dist/TAK Manager.app' TAK-Wrapper/dist/", 17 | "package:win": "set VERSION=%npm_package_version% && npm run image:win --workspace=client && npm run package --workspace=TAK-Wrapper/web && iscc TAK-Wrapper/inno-tak.iss", 18 | "merge": "git checkout main && git pull origin main && git merge dev && git push origin main && git checkout dev", 19 | "release": "npm run update:version && npm run merge && node dev_scripts/release.js" 20 | }, 21 | "devDependencies": { 22 | "js-yaml": "^4.1.0", 23 | "prompt-sync": "^4.2.0" 24 | }, 25 | "author": "Jacob Olsen", 26 | "description": "TAK Manager - A comprehensive TAK Server management solution", 27 | "dependencies": { 28 | "@radix-ui/react-tooltip": "^1.1.8" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Tak Manager 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/progress.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ProgressPrimitive from "@radix-ui/react-progress" 3 | import { cn } from "@/lib/utils" 4 | 5 | interface ProgressProps extends React.ComponentPropsWithoutRef { 6 | /** Whether the progress is in indeterminate state */ 7 | isIndeterminate?: boolean; 8 | /** Optional text to display */ 9 | text?: string; 10 | } 11 | 12 | const Progress = React.forwardRef< 13 | React.ElementRef, 14 | ProgressProps 15 | >(({ className, value, isIndeterminate, text, ...props }, ref) => ( 16 |
17 | 25 | 32 | 33 | {text && ( 34 |
35 | {text} 36 |
37 | )} 38 |
39 | )) 40 | 41 | Progress.displayName = ProgressPrimitive.Root.displayName 42 | 43 | export { Progress } 44 | export type { ProgressProps } 45 | -------------------------------------------------------------------------------- /client/src/components/shared/hooks/useTakServerRequired.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useMemo } from 'react'; 2 | import { useTakServer } from '../ui/shadcn/sidebar/app-sidebar'; 3 | 4 | interface UseTakServerRequiredOptions { 5 | onServerStarted?: () => void; 6 | title?: string; 7 | description?: string; 8 | suppressDuringRestart?: boolean; 9 | } 10 | 11 | export const useTakServerRequired = (options: UseTakServerRequiredOptions = {}) => { 12 | const { serverState } = useTakServer(); 13 | const [showDialog, setShowDialog] = useState(false); 14 | 15 | const shouldShowDialog = useMemo(() => { 16 | if (options.suppressDuringRestart && serverState?.isRestarting) return false; 17 | if (serverState?.isRestarting) return false; 18 | return !serverState?.isRunning; 19 | }, [serverState?.isRunning, serverState?.isRestarting, options.suppressDuringRestart]); 20 | 21 | useEffect(() => { 22 | setShowDialog(shouldShowDialog); 23 | }, [shouldShowDialog]); 24 | 25 | // Close dialog and call callback when server becomes running 26 | useEffect(() => { 27 | if (serverState?.isRunning && showDialog) { 28 | setShowDialog(false); 29 | options.onServerStarted?.(); 30 | } 31 | }, [serverState?.isRunning, showDialog, options.onServerStarted]); 32 | 33 | const dialogProps = { 34 | isOpen: showDialog, 35 | onOpenChange: setShowDialog, 36 | title: options.title, 37 | description: options.description 38 | }; 39 | 40 | return { 41 | showDialog: setShowDialog, 42 | dialogProps, 43 | isServerRunning: serverState?.isRunning ?? false 44 | }; 45 | }; 46 | 47 | export default useTakServerRequired; -------------------------------------------------------------------------------- /server/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import uvicorn 4 | 5 | 6 | # Add the server directory to Python path 7 | server_dir = os.path.dirname(os.path.abspath(__file__)) 8 | sys.path.insert(0, server_dir) 9 | 10 | # Import our modules after path setup 11 | from backend import create_app 12 | from backend.config.logging_config import configure_logging 13 | 14 | # Configure logging 15 | logger = configure_logging(__name__) 16 | 17 | def start_server(): 18 | """Start the FastAPI server.""" 19 | try: 20 | # Get configuration 21 | port = os.getenv('BACKEND_PORT') 22 | if port is None: 23 | logger.error("BACKEND_PORT environment variable is not set.") 24 | raise ValueError("BACKEND_PORT must be set.") 25 | port = int(port) 26 | 27 | is_dev = os.getenv('MODE', 'development') == 'development' 28 | 29 | # Create and configure FastAPI app 30 | app = create_app() 31 | 32 | if is_dev: 33 | logger.info("Starting development server with auto-reload") 34 | uvicorn.run("app:create_app", host="0.0.0.0", port=port, reload=True, factory=True) 35 | else: 36 | logger.info("Starting production server") 37 | uvicorn.run(app, host="0.0.0.0", port=port) 38 | 39 | except Exception as e: 40 | logger.error(f"Server failed to start: {e}", exc_info=True) 41 | raise 42 | 43 | if __name__ == '__main__': 44 | try: 45 | start_server() 46 | except KeyboardInterrupt: 47 | logger.info("Server shutting down...") 48 | except Exception as e: 49 | logger.error(f"Unexpected error: {e}") 50 | sys.exit(1) -------------------------------------------------------------------------------- /client/src/components/takserver/components/TakServerStatus/StatusDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface TakServerState { 4 | isInstalled: boolean; 5 | isRunning: boolean; 6 | version?: string; 7 | error?: string | null; 8 | } 9 | 10 | interface StatusDisplayProps { 11 | takState: TakServerState; 12 | } 13 | 14 | const StatusDisplay: React.FC = ({ takState}) => ( 15 |
16 |
17 |

18 | Status: 19 | 20 | {takState.isInstalled && ( 21 | 22 | {takState.isRunning ? ( 23 | <> 24 | 25 | 26 | 27 | ) : ( 28 | 29 | )} 30 | 31 | )} 32 | 33 | {takState.isInstalled ? (takState.isRunning ? 'Running' : 'Stopped') : 'Not Installed'} 34 | 35 | 36 |

37 | {takState.isInstalled && takState.version && ( 38 |

39 | Version: {takState.version} 40 |

41 | )} 42 |
43 |
44 | ); 45 | 46 | export default StatusDisplay; -------------------------------------------------------------------------------- /client/src/components/datapackage/AtakPreferencesSection/atakConfirmDefaults.tsx: -------------------------------------------------------------------------------- 1 | import { RotateCcw } from 'lucide-react'; 2 | import { Button } from "@/components/shared/ui/shadcn/button"; 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogDescription, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogTrigger, 10 | DialogFooter, 11 | DialogClose, 12 | } from "@/components/shared/ui/shadcn/dialog"; 13 | 14 | interface AtakConfirmDefaultsProps { 15 | handleReset: () => void; 16 | showDefaultDialog: boolean; 17 | onDefaultDialogChange: (open: boolean) => void; 18 | } 19 | 20 | export const AtakConfirmDefaults = ({ 21 | handleReset, 22 | showDefaultDialog, 23 | onDefaultDialogChange 24 | }: AtakConfirmDefaultsProps) => ( 25 | 26 | 27 | 33 | 34 | 35 | 36 | Confirm Reset 37 | 38 | Are you sure you want to reset all settings to default values? 39 | 40 | 41 | 42 | 43 | 44 | 45 | 54 | 55 | 56 | 57 | ); 58 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/inputs/ContainerStartStopButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { faPlay, faStop} from '@fortawesome/free-solid-svg-icons'; 4 | import { Button } from '../shadcn/button'; 5 | import { cn } from '../../../../lib/utils'; 6 | 7 | export interface ContainerStateIconProps { 8 | name: string; 9 | isRunning: boolean; 10 | isLoading?: boolean; 11 | disabled?: boolean; 12 | onOperation: (name: string, action: string) => void; 13 | } 14 | 15 | const ContainerStateIcon: React.FC = ({ 16 | name, 17 | isRunning, 18 | isLoading = false, 19 | disabled = false, 20 | onOperation, 21 | }) => { 22 | const handleClick = useCallback(() => { 23 | if (isLoading || disabled) { 24 | return; 25 | } 26 | 27 | const action = isRunning ? 'stop' : 'start'; 28 | onOperation(name, action); 29 | }, [isLoading, isRunning, onOperation, name, disabled]); 30 | 31 | const getStatusColor = () => { 32 | if (isLoading) return ""; 33 | if (disabled) return "text-gray-400"; 34 | return isRunning ? "text-red-500 hover:text-red-600" : "text-green-500 hover:text-green-600"; 35 | }; 36 | 37 | return ( 38 | 55 | ); 56 | }; 57 | 58 | export default React.memo(ContainerStateIcon); -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/tooltip/HelpIconTooltip.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { HelpCircle } from 'lucide-react'; 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipProvider, 7 | TooltipTrigger, 8 | } from './tooltip'; 9 | 10 | interface HelpIconTooltipProps { 11 | tooltip: string; 12 | iconSize?: number; 13 | className?: string; 14 | triggerMode?: 'click' | 'hover'; 15 | side?: 'top' | 'right' | 'bottom' | 'left'; 16 | showIcon?: boolean; 17 | children?: React.ReactNode; 18 | tooltipDelay?: number; 19 | } 20 | 21 | export const HelpIconTooltip = ({ 22 | tooltip, 23 | iconSize = 16, 24 | className = '', 25 | triggerMode = 'click', 26 | side = 'top', 27 | showIcon = true, 28 | children, 29 | tooltipDelay = 200, 30 | }: HelpIconTooltipProps) => { 31 | const [isOpen, setIsOpen] = useState(false); 32 | 33 | if (!tooltip) return null; 34 | 35 | const handleClick = (e: React.MouseEvent) => { 36 | if (triggerMode === 'click') { 37 | e.preventDefault(); 38 | e.stopPropagation(); 39 | setIsOpen(!isOpen); 40 | } 41 | }; 42 | 43 | const tooltipTrigger = showIcon ? ( 44 |
48 | 52 |
53 | ) : ( 54 | children 55 | ); 56 | 57 | return ( 58 | 59 | 63 | 64 | {tooltipTrigger} 65 | 66 | 67 | {tooltip} 68 | 69 | 70 | 71 | ); 72 | }; 73 | 74 | export default HelpIconTooltip; -------------------------------------------------------------------------------- /client/src/utils/uploadProgress.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Upload file with progress tracking 3 | * 4 | * @param url - API endpoint URL 5 | * @param formData - FormData object with file and other fields 6 | * @param onProgress - Callback for upload progress updates (0-100) 7 | * @param timeout - Request timeout in milliseconds (default: 3600000 = 1 hour) 8 | * @returns Promise with the response 9 | */ 10 | export const uploadWithProgress = ( 11 | url: string, 12 | formData: FormData, 13 | onProgress: (progress: number) => void, 14 | timeout: number = 3600000 15 | ): Promise => { 16 | return new Promise((resolve, reject) => { 17 | const xhr = new XMLHttpRequest(); 18 | 19 | // Set up progress event 20 | xhr.upload.addEventListener('progress', (event) => { 21 | if (event.lengthComputable) { 22 | const percentComplete = Math.round((event.loaded / event.total) * 100); 23 | onProgress(percentComplete); 24 | } 25 | }); 26 | 27 | // Set up load event 28 | xhr.addEventListener('load', () => { 29 | if (xhr.status >= 200 && xhr.status < 300) { 30 | // Create a Response object to mimic fetch API 31 | const response = new Response(xhr.response, { 32 | status: xhr.status, 33 | statusText: xhr.statusText, 34 | headers: new Headers({ 35 | 'Content-Type': xhr.getResponseHeader('Content-Type') || 'application/json' 36 | }) 37 | }); 38 | resolve(response); 39 | } else { 40 | reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.statusText}`)); 41 | } 42 | }); 43 | 44 | // Set up error event 45 | xhr.addEventListener('error', () => { 46 | reject(new Error('Network error during upload')); 47 | }); 48 | 49 | // Set up timeout 50 | xhr.addEventListener('timeout', () => { 51 | reject(new Error('Upload request timed out')); 52 | }); 53 | xhr.timeout = timeout; 54 | 55 | // Open and send the request 56 | xhr.open('POST', url); 57 | xhr.send(formData); 58 | }); 59 | }; -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TabsPrimitive from "@radix-ui/react-tabs" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Tabs = TabsPrimitive.Root 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | TabsList.displayName = TabsPrimitive.List.displayName 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )) 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )) 51 | TabsContent.displayName = TabsPrimitive.Content.displayName 52 | 53 | export { Tabs, TabsList, TabsTrigger, TabsContent } 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "tak-manager" 3 | version = "3.1.2" 4 | description = "A comprehensive TAK Server management solution providing a modern web interface for managing and monitoring TAK (Team Awareness Kit) servers." 5 | authors = [ 6 | { name = "JShadowNull", email = "95039572+JShadowNull@users.noreply.github.com" }, 7 | ] 8 | readme = "README.md" 9 | requires-python = ">=3.13.0,<3.14" 10 | dependencies = [ 11 | "annotated-types (>=0.7.0,<1.0.0)", 12 | "anyio (>=4.8.0,<5.0.0)", 13 | "bottle (>=0.13.2,<0.14.0)", 14 | "certifi (>=2025.1.31,<2026.0.0)", 15 | "charset-normalizer (>=3.4.1,<4.0.0)", 16 | "click (>=8.1.8,<9.0.0)", 17 | "docker (>=7.1.0,<8.0.0)", 18 | "fastapi (>=0.115.12,<0.116.0)", 19 | "h11 (>=0.14.0,<0.15.0)", 20 | "idna (>=3.10,<4.0.0)", 21 | "packaging (>=24.2,<25.0.0)", 22 | "pip (>=25.0.1,<26.0.0)", 23 | "platformdirs (>=4.3.6,<5.0.0)", 24 | "proxy_tools (>=0.1.0,<0.2.0)", 25 | "psutil (>=7.0.0,<8.0.0)", 26 | "pydantic (>=2.10.6,<3.0.0)", 27 | "pydantic_core (>=2.27.2,<3.0.0)", 28 | "pywebview (>=5.4,<6.0.0)", 29 | "requests (>=2.32.3,<3.0.0)", 30 | "sniffio (>=1.3.1,<2.0.0)", 31 | "starlette (>=0.46.1,<0.47.0)", 32 | "typing_extensions (>=4.12.2,<5.0.0)", 33 | "urllib3 (>=2.3.0,<3.0.0)", 34 | "uvicorn (>=0.34.0,<1.0.0)", 35 | "pyinstaller (>=6.12.0,<7.0.0)", 36 | "setuptools (>=78.1.0,<79.0.0)", 37 | "pyinstaller-hooks-contrib (>=2025.1,<2026.0)", 38 | "altgraph (>=0.17.4,<1.0.0); sys_platform == 'darwin'", 39 | "macholib (>=1.16.3,<2.0.0); sys_platform == 'darwin'", 40 | "pyobjc-core (>=11.0,<12.0); sys_platform == 'darwin'", 41 | "pyobjc-framework-Cocoa (>=11.0,<12.0); sys_platform == 'darwin'", 42 | "pyobjc-framework-Quartz (>=11.0,<12.0); sys_platform == 'darwin'", 43 | "pyobjc-framework-Security (>=11.0,<12.0); sys_platform == 'darwin'", 44 | "pyobjc-framework-WebKit (>=11.0,<12.0); sys_platform == 'darwin'", 45 | "pyqt6 (>=6.8.1,<7.0.0)", 46 | ] 47 | 48 | [build-system] 49 | requires = ["poetry-core>=2.0.0,<3.0.0"] 50 | build-backend = "poetry.core.masonry.api" 51 | 52 | [tool.poetry] 53 | package-mode = false 54 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import './styles/tailwind.css'; 5 | import { library } from '@fortawesome/fontawesome-svg-core'; 6 | import { faPlay, faStop, faSpinner } from '@fortawesome/free-solid-svg-icons'; 7 | import { registerSW } from 'virtual:pwa-register'; 8 | 9 | // Register service worker with improved configuration for Android compatibility 10 | const updateSW = registerSW({ 11 | immediate: true, 12 | onNeedRefresh() { 13 | if (confirm('New content available. Reload application?')) { 14 | updateSW(true); 15 | } 16 | }, 17 | onOfflineReady() { 18 | console.log('App ready to work offline'); 19 | }, 20 | onRegistered(registration: ServiceWorkerRegistration | undefined) { 21 | console.log('Service worker registered:', registration); 22 | 23 | // Force update check periodically (helps with Android refresh issues) 24 | setInterval(() => { 25 | if (registration) { 26 | registration.update().catch(console.error); 27 | } 28 | }, 60 * 60 * 1000); // Check every hour 29 | }, 30 | onRegisterError(error: any) { 31 | console.error('Service worker registration failed:', error); 32 | } 33 | }); 34 | 35 | // Handle Android back button correctly in standalone mode 36 | if (window.matchMedia('(display-mode: standalone)').matches) { 37 | window.addEventListener('load', () => { 38 | window.addEventListener('popstate', () => { 39 | if (window.history.state === null && window.location.pathname === '/') { 40 | // We're at the root with no history state, likely a back button from the root 41 | if (navigator.userAgent.includes('Android')) { 42 | // On Android, this should exit the app instead of navigating back 43 | window.close(); 44 | } 45 | } 46 | }); 47 | }); 48 | } 49 | 50 | // Add icons to the library 51 | library.add(faPlay, faStop, faSpinner); 52 | 53 | const rootElement = document.getElementById('root'); 54 | if (!rootElement) throw new Error('Failed to find the root element'); 55 | 56 | ReactDOM.createRoot(rootElement).render( 57 | 58 | 59 | 60 | ); -------------------------------------------------------------------------------- /client/src/components/transfer/TransferStatus/StatusButtons.jsx: -------------------------------------------------------------------------------- 1 | import { Button } from '../../shared/ui/shadcn/button'; 2 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../shared/ui/shadcn/tooltip/tooltip'; 3 | 4 | export const StatusButtons = ({ 5 | isTransferRunning, 6 | filesExist, 7 | isDeviceConnected, 8 | onStartTransfer, 9 | onStopTransfer 10 | }) => { 11 | const getStartButtonTooltip = () => { 12 | if (!filesExist) return 'No files available to transfer'; 13 | if (!isDeviceConnected) return 'No device connected'; 14 | if (isTransferRunning) return 'Transfer in progress'; 15 | return 'Start file transfer to connected devices'; 16 | }; 17 | 18 | return ( 19 |
20 | 21 | 22 | 23 | 24 | 33 | 34 | 35 | 36 |

{getStartButtonTooltip()}

37 |
38 |
39 |
40 | 41 | 42 | 43 | 44 | 45 | 54 | 55 | 56 | 57 |

{isTransferRunning ? 'Stop current transfer' : 'No transfer in progress'}

58 |
59 |
60 |
61 |
62 | ); 63 | }; -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/card/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "../../../../../lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | prod: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | args: 7 | ENV: production 8 | image: tak-manager:3.1.2 9 | container_name: tak-manager-production 10 | restart: unless-stopped 11 | env_file: 12 | - .env 13 | environment: 14 | - PYTHONUNBUFFERED=1 15 | - BACKEND_PORT=${BACKEND_PORT:-8003} 16 | - TAK_SERVER_INSTALL_DIR=${TAK_SERVER_INSTALL_DIR} 17 | volumes: 18 | - /var/run/docker.sock:/var/run/docker.sock 19 | - ${TAK_SERVER_INSTALL_DIR}/tak-manager/data:/home/tak-manager:rw 20 | - ${TAK_SERVER_INSTALL_DIR}/tak-manager/logs:/app/logs:rw 21 | ports: 22 | - ${BACKEND_PORT:-8003}:${BACKEND_PORT:-8003} 23 | networks: 24 | - tak-manager-network 25 | healthcheck: 26 | test: 27 | - CMD 28 | - curl 29 | - '-f' 30 | - http://localhost:${BACKEND_PORT}/health 31 | interval: ${HEALTHCHECK_INTERVAL:-30s} 32 | timeout: ${HEALTHCHECK_TIMEOUT:-10s} 33 | retries: ${HEALTHCHECK_RETRIES:-3} 34 | dev: 35 | build: 36 | context: . 37 | dockerfile: Dockerfile 38 | args: 39 | ENV: development 40 | image: tak-manager-development:latest 41 | container_name: tak-manager-development 42 | restart: ${RESTART_POLICY:-always} 43 | privileged: true 44 | env_file: 45 | - .env 46 | environment: 47 | - PYTHONUNBUFFERED=1 48 | volumes: 49 | - /var/run/docker.sock:/var/run/docker.sock 50 | - ./logs:/app/logs:rw 51 | - ./server:/app/server:rw 52 | - ./client:/app/client:rw 53 | - node_modules:/app/client/node_modules 54 | - ${TAK_SERVER_INSTALL_DIR}/tak-manager/data:/home/tak-manager:rw 55 | ports: 56 | - ${BACKEND_PORT:-8003}:${BACKEND_PORT:-8003} 57 | - ${FRONTEND_PORT:-5174}:${FRONTEND_PORT:-5174} 58 | networks: 59 | - tak-manager-network 60 | healthcheck: 61 | test: 62 | - CMD 63 | - curl 64 | - '-f' 65 | - http://localhost:${BACKEND_PORT}/health 66 | interval: ${HEALTHCHECK_INTERVAL:-30s} 67 | timeout: ${HEALTHCHECK_TIMEOUT:-10s} 68 | retries: ${HEALTHCHECK_RETRIES:-3} 69 | volumes: 70 | node_modules: null 71 | networks: 72 | tak-manager-network: 73 | driver: bridge 74 | -------------------------------------------------------------------------------- /client/src/components/transfer/FileUpload/index.jsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { UploadIcon } from '../../shared/ui/icons/UploadIcon'; 3 | import { FileList } from './FileList'; 4 | 5 | export const FileUpload = ({ 6 | files, 7 | uploadingFiles, 8 | onFileUpload, 9 | onDeleteFile, 10 | disabled 11 | }) => { 12 | const fileInputRef = useRef(null); 13 | 14 | const handleFileChange = (e) => { 15 | onFileUpload(e.target.files); 16 | if (fileInputRef.current) { 17 | fileInputRef.current.value = ''; 18 | } 19 | }; 20 | 21 | return ( 22 |
23 |

File Upload

24 |
25 |
{ 28 | e.preventDefault(); 29 | e.currentTarget.classList.add('border-green-500'); 30 | }} 31 | onDragLeave={(e) => { 32 | e.preventDefault(); 33 | e.currentTarget.classList.remove('border-green-500'); 34 | }} 35 | onDrop={(e) => { 36 | e.preventDefault(); 37 | e.currentTarget.classList.remove('border-green-500'); 38 | onFileUpload(e.dataTransfer.files); 39 | if (fileInputRef.current) { 40 | fileInputRef.current.value = ''; 41 | } 42 | }} 43 | > 44 | 54 | 61 |
62 | 63 | 69 |
70 |
71 | ); 72 | }; -------------------------------------------------------------------------------- /client/src/components/certmanager/CertificateOperationPopups.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '../shared/ui/shadcn/dialog'; 3 | import { Button } from '../shared/ui/shadcn/button'; 4 | 5 | interface CertificateOperationPopupsProps { 6 | showSingleDeleteConfirm: boolean; 7 | showBatchDeleteConfirm: boolean; 8 | selectedCertName?: string; 9 | selectedCount?: number; 10 | onSingleDeleteConfirm: () => void; 11 | onBatchDeleteConfirm: () => void; 12 | onClose: () => void; 13 | } 14 | 15 | const CertificateOperationPopups: React.FC = ({ 16 | showSingleDeleteConfirm, 17 | showBatchDeleteConfirm, 18 | selectedCertName, 19 | selectedCount, 20 | onSingleDeleteConfirm, 21 | onBatchDeleteConfirm, 22 | onClose, 23 | }) => { 24 | return ( 25 | <> 26 | {/* Single Delete Confirmation Dialog */} 27 | 28 | e.preventDefault()} 30 | onEscapeKeyDown={(e) => e.preventDefault()} 31 | > 32 | 33 | Confirm Delete 34 | 35 | Are you sure you want to delete the certificate for "{selectedCertName}"? This action cannot be undone. 36 | 37 | 38 | 39 | 42 | 45 | 46 | 47 | 48 | 49 | {/* Batch Delete Confirmation Dialog */} 50 | 51 | e.preventDefault()} 53 | onEscapeKeyDown={(e) => e.preventDefault()} 54 | > 55 | 56 | Confirm Delete 57 | 58 | Are you sure you want to delete {selectedCount} selected certificates? This action cannot be undone. 59 | 60 | 61 | 62 | 65 | 68 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | 75 | export default CertificateOperationPopups; -------------------------------------------------------------------------------- /client/src/components/transfer/FileUpload/FileList.jsx: -------------------------------------------------------------------------------- 1 | import { CloseIcon } from '../../shared/ui/icons/CloseIcon'; 2 | import { LoadingSpinner } from '../../shared/ui/icons/LoadingSpinner'; 3 | import { ScrollArea } from '@/components/shared/ui/shadcn/scroll-area'; 4 | 5 | export const FileList = ({ files, uploadingFiles, onDeleteFile, disabled }) => { 6 | const itemHeight = 36; // py-2 (8px top + 8px bottom) + line height (16px) 7 | const visibleItems = 4; 8 | const maxHeight = itemHeight * visibleItems; 9 | 10 | const totalItems = files.length + uploadingFiles.size; 11 | const containerHeight = Math.min(totalItems * itemHeight, maxHeight); 12 | 13 | return ( 14 | (files.length > 0 || uploadingFiles.size > 0) && ( 15 |
16 |
17 | 18 |
19 | {/* Show uploading files first */} 20 | {Array.from(uploadingFiles).map((filename) => ( 21 |
25 | {filename} 26 |
27 | Uploading... 28 | 29 |
30 |
31 | ))} 32 | 33 | {/* Show uploaded files */} 34 | {files.map((filename) => ( 35 |
39 | {filename} 40 | 52 |
53 | ))} 54 |
55 |
56 |
57 |
58 | ) 59 | ); 60 | }; -------------------------------------------------------------------------------- /client/src/components/datapackage/ExistingDataPackages/PackageOperationPopups.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/shared/ui/shadcn/dialog'; 3 | import { Button } from '@/components/shared/ui/shadcn/button'; 4 | 5 | interface PackageOperationPopupsProps { 6 | showSingleDeleteConfirm: boolean; 7 | showBatchDeleteConfirm: boolean; 8 | selectedPackageName?: string; 9 | selectedCount?: number; 10 | onSingleDeleteConfirm: () => void; 11 | onBatchDeleteConfirm: () => void; 12 | onClose: () => void; 13 | } 14 | 15 | const PackageOperationPopups: React.FC = ({ 16 | showSingleDeleteConfirm, 17 | showBatchDeleteConfirm, 18 | selectedPackageName, 19 | selectedCount, 20 | onSingleDeleteConfirm, 21 | onBatchDeleteConfirm, 22 | onClose, 23 | }) => { 24 | return ( 25 | <> 26 | {/* Single Delete Confirmation Dialog */} 27 | 28 | e.preventDefault()} 30 | onEscapeKeyDown={(e) => e.preventDefault()} 31 | > 32 | 33 | Confirm Delete 34 | 35 | Are you sure you want to delete the data package "{selectedPackageName}"? This action cannot be undone. 36 | 37 | 38 | 39 | 42 | 45 | 46 | 47 | 48 | 49 | {/* Batch Delete Confirmation Dialog */} 50 | 51 | e.preventDefault()} 53 | onEscapeKeyDown={(e) => e.preventDefault()} 54 | > 55 | 56 | Confirm Delete 57 | 58 | Are you sure you want to delete {selectedCount} selected data packages? This action cannot be undone. 59 | 60 | 61 | 62 | 65 | 68 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | 75 | export default PackageOperationPopups; -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | ARG ENV=prod 4 | 5 | # Stage 1: Frontend builder for production 6 | FROM node:22-slim AS frontend-builder 7 | WORKDIR /app/client 8 | COPY client/package*.json ./ 9 | RUN npm install 10 | COPY client/ . 11 | RUN npm run build 12 | 13 | # Stage 2: Base image with common dependencies 14 | FROM python:3.13.2-slim AS base 15 | WORKDIR /app 16 | 17 | # Docker repository setup (needed for both dev and prod) 18 | RUN apt-get update && \ 19 | apt-get install -y --no-install-recommends curl && \ 20 | install -m 0755 -d /etc/apt/keyrings && \ 21 | curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \ 22 | chmod a+r /etc/apt/keyrings/docker.asc && \ 23 | echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ 24 | https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ 25 | > /etc/apt/sources.list.d/docker.list && \ 26 | apt-get update && \ 27 | apt-get install -y --no-install-recommends \ 28 | docker-ce-cli \ 29 | docker-compose-plugin \ 30 | unzip \ 31 | zip \ 32 | libxml2-utils \ 33 | sed && \ 34 | rm -rf /var/lib/apt/lists/* 35 | 36 | # Stage 3: Development environment 37 | FROM base AS development 38 | WORKDIR /app 39 | 40 | # Node.js installation for development 41 | RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ 42 | apt-get install -y nodejs=22.13.1-1nodesource1 && \ 43 | rm -rf /var/lib/apt/lists/* 44 | 45 | # Client dependencies setup with cache 46 | WORKDIR /app/client 47 | COPY client/package.json ./ 48 | COPY package-lock.json ./ 49 | RUN npm install --prefer-offline 50 | 51 | # Python dependencies with Poetry - Install globally 52 | WORKDIR /app/server 53 | COPY server/pyproject.toml ./ 54 | COPY server/poetry.lock ./ 55 | RUN pip install poetry && \ 56 | poetry config virtualenvs.create false && \ 57 | poetry install 58 | 59 | # Application setup 60 | RUN mkdir -p /app/logs && chmod -R 755 /app/logs 61 | COPY . . 62 | 63 | # Dev command to start both frontend and backend 64 | CMD ["/bin/sh", "-c", "cd /app/client && npm run start & cd /app/server && poetry run python app.py"] 65 | 66 | # Stage 4: Production environment 67 | FROM base AS production 68 | WORKDIR /app 69 | 70 | # Python dependencies with Poetry - Install globally 71 | WORKDIR /app/server 72 | COPY server/pyproject.toml ./ 73 | COPY server/poetry.lock ./ 74 | RUN pip install poetry && \ 75 | poetry config virtualenvs.create false && \ 76 | poetry install 77 | 78 | # Copy built frontend and backend files 79 | COPY --from=frontend-builder /app/client/build /app/client/build 80 | COPY server/ /app/server/ 81 | 82 | # Create required directories 83 | RUN mkdir -p /app/logs && \ 84 | chmod -R 755 /app/logs 85 | 86 | CMD ["poetry", "run", "python", "/app/server/app.py"] 87 | 88 | # Final image based on ARG ENV 89 | FROM ${ENV} AS final 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TAK Manager 2 | [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-F7941E?style=for-the-badge&logo=buy-me-a-coffee&logoColor=white)](https://ko-fi.com/jakeolsen) 3 | 4 | A comprehensive TAK Server management solution providing a modern web interface for managing and monitoring TAK (Team Awareness Kit) servers. 5 | 6 | image 7 | 8 | ## Quick Start 9 | 10 | ### Download and Install 11 | 12 | #### macOS 13 | 1. Download the latest DMG installer from the [releases page](https://github.com/JShadowNull/TAK-Manager/releases/latest) 14 | 2. Open the DMG file 15 | 3. Drag the TAK Manager application to your Applications folder 16 | 4. Open TAK Manager from your Applications folder 17 | 18 | #### Windows 19 | 1. Download the latest EXE installer from the [releases page](https://github.com/JShadowNull/TAK-Manager/releases/latest) 20 | 2. Run the installer 21 | 3. Follow the installation wizard 22 | 4. Launch TAK Manager from the Start menu 23 | 24 | #### Linux 25 | For Linux, manual installation is required: 26 | 27 | 1. Clone the repository: 28 | ```bash 29 | git clone https://github.com/JShadowNull/TAK-Manager.git 30 | cd TAK-Manager 31 | ``` 32 | 33 | 2. Create environment file: 34 | ```bash 35 | cp .env.example .env 36 | ``` 37 | Edit the `.env` file with your specific configuration. 38 | 39 | 3. Start the Docker container: 40 | ```bash 41 | docker compose -f docker-compose.yml up prod -d 42 | ``` 43 | 44 | The application will be available at `http://localhost:8989` (or your configured port). 45 | 46 | To stop the container: 47 | ```bash 48 | docker compose -f docker-compose.yml down 49 | ``` 50 | 51 | ## Features 52 | 53 | - Modern web-based interface built with React and TypeScript 54 | - Real-time system monitoring (CPU, RAM, and Network usage) 55 | - Responsive design with a beautiful UI using Tailwind CSS and Shadcn UI 56 | - Advanced TAK Server management features: 57 | - One-click TAK Server installation 58 | - One-click OTA Plugin configuration 59 | - Start, stop, and restart TAK Server 60 | - Certificate management and configuration 61 | - Data Package management 62 | - ATAK preferences configuration 63 | - Advanced logging configuration 64 | - CoreConfig Editor 65 | 66 | ## System Requirements 67 | 68 | - **macOS**: macOS 11 (Big Sur) or later 69 | - **Windows**: Windows 10 or later 70 | - **Linux**: Any modern Linux distribution with Docker support 71 | 72 | ## Configuration 73 | 74 | After installation, configure TAK Manager to connect to your TAK Server: 75 | 76 | 1. Launch TAK Manager 77 | 2. Install Docker if not already installed 78 | 3. Enter your TAK Manager details: 79 | - TAK Server installation directory 80 | - Backend API port 81 | 82 | ## For Developers 83 | 84 | If you're interested in developing or contributing to TAK Manager, please see the [Development README](README.DEV.md) for detailed instructions. 85 | 86 | ## Author 87 | 88 | Jacob Olsen 89 | 90 | -------------------------------------------------------------------------------- /client/src/components/transfer/TransferStatus/index.jsx: -------------------------------------------------------------------------------- 1 | import { DeviceProgress } from './DeviceProgress'; 2 | import { StatusButtons } from './StatusButtons'; 3 | 4 | export const TransferStatus = ({ 5 | deviceStatus, 6 | deviceProgress, 7 | isTransferRunning, 8 | filesExist, 9 | onRemoveFailed, 10 | onStartTransfer, 11 | onStopTransfer 12 | }) => { 13 | // Get connected device IDs and status from deviceStatus 14 | const connectedDevices = new Set( 15 | deviceStatus.devices ? Object.keys(deviceStatus.devices) : [] 16 | ); 17 | 18 | const getDeviceStatusColor = () => { 19 | if (!deviceStatus) return 'text-yellow-500'; 20 | if (deviceStatus.isConnected) { 21 | return isTransferRunning ? 'text-blue-500' : 'text-green-500'; 22 | } 23 | return 'text-yellow-500'; 24 | }; 25 | 26 | const getDeviceStatusText = () => { 27 | if (!deviceStatus) return 'Checking device status...'; 28 | if (!deviceStatus.text) { 29 | return deviceStatus.isConnected 30 | ? `Connected devices: ${Array.from(connectedDevices).join(', ')}` 31 | : 'Waiting for device...'; 32 | } 33 | return deviceStatus.text; 34 | }; 35 | 36 | return ( 37 |
38 |
39 |

Transfer Status

40 | 0} 44 | onStartTransfer={onStartTransfer} 45 | onStopTransfer={onStopTransfer} 46 | /> 47 |
48 | 49 |
50 | {/* Device Status Section */} 51 |
52 | Device Status: 53 | 54 | {getDeviceStatusText()} 55 | 56 |
57 | 58 | {/* Device Progress Section */} 59 |
60 | {Object.entries(deviceProgress || {}).map(([deviceId, progress]) => ( 61 | 69 | ))} 70 | 71 | {/* No Progress Message */} 72 | {(!deviceProgress || Object.keys(deviceProgress).length === 0) && ( 73 |
74 | {isTransferRunning 75 | ? 'Initializing transfer...' 76 | : connectedDevices.size > 0 77 | ? 'Ready to transfer files' 78 | : 'Connect a device to begin transfer'} 79 |
80 | )} 81 |
82 |
83 |
84 | ); 85 | }; -------------------------------------------------------------------------------- /server/backend/routes/port_manager_routes.py: -------------------------------------------------------------------------------- 1 | # backend/routes/port_manager_routes.py 2 | 3 | from fastapi import APIRouter, HTTPException 4 | from pydantic import BaseModel 5 | from typing import List, Optional 6 | from backend.services.scripts.docker.docker_compose_editor import DockerComposeEditor 7 | from backend.config.logging_config import configure_logging 8 | 9 | logger = configure_logging(__name__) 10 | 11 | # Router setup 12 | portmanager = APIRouter() 13 | 14 | # Response Models 15 | class PortResponse(BaseModel): 16 | status: str 17 | message: str 18 | 19 | class PortMappingsResponse(BaseModel): 20 | status: str 21 | port_mappings: List[str] 22 | 23 | class PortMappingRequest(BaseModel): 24 | host_port: int 25 | container_port: Optional[int] = None 26 | 27 | @portmanager.get('/ports', response_model=PortMappingsResponse) 28 | async def get_takserver_port_mappings(): 29 | """Get the current port mappings for the takserver service""" 30 | try: 31 | # Get the port mappings 32 | port_mappings = DockerComposeEditor.get_takserver_port_mappings() 33 | 34 | return PortMappingsResponse( 35 | status='success', 36 | port_mappings=port_mappings 37 | ) 38 | except Exception as e: 39 | logger.error(f"Failed to get port mappings: {str(e)}") 40 | raise HTTPException( 41 | status_code=500, 42 | detail=str(e) 43 | ) 44 | 45 | @portmanager.post('/ports/add', response_model=PortResponse) 46 | async def add_port_to_takserver(port_mapping: PortMappingRequest): 47 | """Add a port mapping to the takserver service in the docker-compose.yml file""" 48 | try: 49 | # Add the port to the docker-compose file 50 | DockerComposeEditor.add_port_to_takserver( 51 | host_port=port_mapping.host_port, 52 | container_port=port_mapping.container_port 53 | ) 54 | 55 | return PortResponse( 56 | status='success', 57 | message=f'Port mapping {port_mapping.host_port}:{port_mapping.container_port or port_mapping.host_port} added successfully' 58 | ) 59 | except Exception as e: 60 | logger.error(f"Failed to add port mapping: {str(e)}") 61 | raise HTTPException( 62 | status_code=500, 63 | detail=str(e) 64 | ) 65 | 66 | @portmanager.post('/ports/remove', response_model=PortResponse) 67 | async def remove_port_from_takserver(port_mapping: PortMappingRequest): 68 | """Remove a port mapping from the takserver service in the docker-compose.yml file""" 69 | try: 70 | # Remove the port from the docker-compose file 71 | DockerComposeEditor.remove_port_from_takserver( 72 | host_port=port_mapping.host_port, 73 | container_port=port_mapping.container_port 74 | ) 75 | 76 | return PortResponse( 77 | status='success', 78 | message=f'Port mapping {port_mapping.host_port}:{port_mapping.container_port or port_mapping.host_port} removed successfully' 79 | ) 80 | except Exception as e: 81 | logger.error(f"Failed to remove port mapping: {str(e)}") 82 | raise HTTPException( 83 | status_code=500, 84 | detail=str(e) 85 | ) -------------------------------------------------------------------------------- /server/backend/services/scripts/docker/docker_manager.py: -------------------------------------------------------------------------------- 1 | # backend/services/scripts/docker_manager.py 2 | 3 | import docker 4 | from typing import Dict, Any, AsyncGenerator, Optional, Callable 5 | import json 6 | import asyncio 7 | from backend.config.logging_config import configure_logging 8 | import time 9 | from sse_starlette.sse import ServerSentEvent 10 | 11 | logger = configure_logging(__name__) 12 | 13 | class DockerManager: 14 | def __init__(self): 15 | self.client = docker.from_env() 16 | self._operation_states = {} # Track operations by container name 17 | 18 | def get_container_status(self): 19 | """Get current container list with operation states""" 20 | containers = [] 21 | for container in self.client.containers.list(all=True): 22 | container_info = { 23 | 'id': container.id, 24 | 'name': container.name, 25 | 'status': container.status, 26 | 'state': container.status, 27 | 'running': container.status == 'running', 28 | 'image': container.image.tags[0] if container.image.tags else 'none', 29 | 'operation': self._operation_states.get(container.name) 30 | } 31 | containers.append(container_info) 32 | 33 | return { 34 | 'type': 'container_status', 35 | 'containers': containers, 36 | 'timestamp': time.time() 37 | } 38 | 39 | async def status_generator(self): 40 | """Generate container status events every 5 seconds""" 41 | while True: 42 | yield self.get_container_status() 43 | await asyncio.sleep(5) 44 | 45 | async def start_container(self, container_name: str): 46 | """Start a container and track its operation state""" 47 | try: 48 | container = self.client.containers.get(container_name) 49 | self._operation_states[container_name] = {'action': 'start', 'status': 'in_progress'} 50 | container.start() 51 | self._operation_states[container_name] = {'action': 'start', 'status': 'completed'} 52 | del self._operation_states[container_name] 53 | except Exception as e: 54 | logger.error(f"Error starting container {container_name}: {str(e)}") # Added error log 55 | self._operation_states[container_name] = {'action': 'start', 'status': 'error', 'error': str(e)} 56 | del self._operation_states[container_name] 57 | raise 58 | 59 | async def stop_container(self, container_name: str): 60 | """Stop a container and track its operation state""" 61 | try: 62 | container = self.client.containers.get(container_name) 63 | self._operation_states[container_name] = {'action': 'stop', 'status': 'in_progress'} 64 | container.stop() 65 | self._operation_states[container_name] = {'action': 'stop', 'status': 'completed'} 66 | del self._operation_states[container_name] 67 | except Exception as e: 68 | logger.error(f"Error stopping container {container_name}: {str(e)}") # Added error log 69 | self._operation_states[container_name] = {'action': 'stop', 'status': 'error', 'error': str(e)} 70 | del self._operation_states[container_name] 71 | raise 72 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:border-b-0", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | 93 | )) 94 | TableCell.displayName = "TableCell" 95 | 96 | const TableCaption = React.forwardRef< 97 | HTMLTableCaptionElement, 98 | React.HTMLAttributes 99 | >(({ className, ...props }, ref) => ( 100 |
105 | )) 106 | TableCaption.displayName = "TableCaption" 107 | 108 | export { 109 | Table, 110 | TableHeader, 111 | TableBody, 112 | TableFooter, 113 | TableHead, 114 | TableRow, 115 | TableCell, 116 | TableCaption, 117 | } 118 | -------------------------------------------------------------------------------- /server/backend/routes/docker_manager_routes.py: -------------------------------------------------------------------------------- 1 | # backend/routes/docker_manager_routes.py 2 | 3 | from fastapi import APIRouter, HTTPException 4 | from fastapi.responses import JSONResponse 5 | from sse_starlette.sse import EventSourceResponse, ServerSentEvent 6 | from pydantic import BaseModel 7 | from typing import Dict, Any 8 | import docker 9 | from backend.services.scripts.docker.docker_manager import DockerManager 10 | from backend.config.logging_config import configure_logging 11 | import json 12 | 13 | logger = configure_logging(__name__) 14 | 15 | # Router setup 16 | dockermanager = APIRouter() 17 | 18 | docker_manager = DockerManager() 19 | 20 | # Response Models 21 | class ContainerResponse(BaseModel): 22 | status: str 23 | message: str 24 | 25 | @dockermanager.get('/containers/status-stream') 26 | async def container_status_stream(): 27 | """SSE endpoint for container status updates""" 28 | async def event_generator(): 29 | async for status in docker_manager.status_generator(): 30 | yield ServerSentEvent(json.dumps(status), event='docker_status') 31 | return EventSourceResponse(event_generator()) 32 | 33 | @dockermanager.post('/containers/updates/start', response_model=ContainerResponse) 34 | async def start_container_updates(): 35 | """Get initial container status""" 36 | try: 37 | status = docker_manager.get_container_status() 38 | return ContainerResponse( 39 | status='success', 40 | message='Container status retrieved' 41 | ) 42 | except Exception as e: 43 | logger.error(f"Failed to get container status: {str(e)}") 44 | raise HTTPException( 45 | status_code=500, 46 | detail=str(e) 47 | ) 48 | 49 | @dockermanager.post('/containers/{container_name}/start', response_model=ContainerResponse) 50 | async def start_container(container_name: str): 51 | """Start a Docker container""" 52 | try: 53 | await docker_manager.start_container(container_name) 54 | return ContainerResponse( 55 | status='success', 56 | message='Container started successfully' 57 | ) 58 | except Exception as e: 59 | raise HTTPException(status_code=500, detail=str(e)) 60 | 61 | @dockermanager.post('/containers/{container_name}/stop', response_model=ContainerResponse) 62 | async def stop_container(container_name: str): 63 | """Stop a Docker container""" 64 | try: 65 | await docker_manager.stop_container(container_name) 66 | return ContainerResponse( 67 | status='success', 68 | message='Container stopped successfully' 69 | ) 70 | except Exception as e: 71 | raise HTTPException(status_code=500, detail=str(e)) 72 | 73 | @dockermanager.post('/container/{container_id}/restart', response_model=ContainerResponse) 74 | async def restart_container(container_id: str): 75 | """Restart a container by ID""" 76 | try: 77 | client = docker.from_env() 78 | container = client.containers.get(container_id) 79 | container.restart() 80 | return ContainerResponse( 81 | status='success', 82 | message='Container restarted successfully' 83 | ) 84 | except Exception as e: 85 | logger.error(f"Failed to restart container {container_id}: {str(e)}") 86 | raise HTTPException( 87 | status_code=500, 88 | detail=str(e) 89 | ) 90 | 91 | 92 | -------------------------------------------------------------------------------- /client/src/components/datapackage/shared/PreferenceItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Input } from "@/components/shared/ui/shadcn/input"; 3 | import { Label } from "@/components/shared/ui/shadcn/label"; 4 | import { Switch } from "@/components/shared/ui/shadcn/switch"; 5 | import { cn } from "@/lib/utils"; 6 | import { HelpIconTooltip } from '@/components/shared/ui/shadcn/tooltip/HelpIconTooltip'; 7 | 8 | export interface PreferenceOption { 9 | value: string; 10 | text: string; 11 | } 12 | 13 | export interface PreferenceItemProps { 14 | name: string; 15 | label: string; 16 | input_type: 'text' | 'select' | 'number' | 'password'; 17 | value: string; 18 | options?: PreferenceOption[]; 19 | onChange: (e: React.ChangeEvent | { target: { value: string } }) => void; 20 | onBlur?: () => void; 21 | onPreferenceEnableChange: (enabled: boolean) => void; 22 | isPreferenceEnabled?: boolean; 23 | isCertificateDropdown?: boolean; 24 | required?: boolean; 25 | placeholder?: string; 26 | defaultValue?: string; 27 | min?: number; 28 | max?: number; 29 | showLabel?: boolean; 30 | showEnableToggle?: boolean; 31 | error?: string; 32 | tooltip?: string; 33 | } 34 | 35 | const PreferenceItem: React.FC = ({ 36 | name, 37 | label, 38 | input_type, 39 | value, 40 | options = [], 41 | onChange, 42 | onBlur, 43 | onPreferenceEnableChange, 44 | isPreferenceEnabled = false, 45 | isCertificateDropdown = false, 46 | required = false, 47 | placeholder = '', 48 | defaultValue, 49 | min, 50 | max, 51 | showLabel = true, 52 | showEnableToggle = true, 53 | error, 54 | tooltip 55 | }) => { 56 | // Use defaultValue if value is empty and defaultValue exists 57 | const effectiveValue = (!value && defaultValue) ? defaultValue : value; 58 | 59 | return ( 60 |
61 |
62 | {showLabel && ( 63 |
64 | 67 | {tooltip && ( 68 | 73 | )} 74 |
75 | )} 76 | {showEnableToggle && ( 77 | 82 | )} 83 |
84 | 102 | {error && ( 103 |

{error}

104 | )} 105 |
106 | ); 107 | }; 108 | 109 | export default PreferenceItem; -------------------------------------------------------------------------------- /client/src/pages/CertManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shared/ui/shadcn/tabs"; 4 | import CreateCertificates from '../components/certmanager/CreateCertificates'; 5 | import ExistingCertificates from '../components/certmanager/ExistingCertificates'; 6 | import { useTakServerRequired } from '../components/shared/hooks/useTakServerRequired'; 7 | import TakServerRequiredDialog from '../components/shared/TakServerRequiredDialog'; 8 | 9 | const CertManager: React.FC = () => { 10 | const navigate = useNavigate(); 11 | const [currentTab, setCurrentTab] = useState(() => { 12 | return sessionStorage.getItem('certManagerTab') || 'create-certs'; 13 | }); 14 | 15 | // TAK Server check 16 | const { showDialog, dialogProps, isServerRunning } = useTakServerRequired({ 17 | title: "TAK Server Required for Certificate Management", 18 | description: "Certificate operations require TAK Server to be running. Would you like to start it now?", 19 | suppressDuringRestart: true 20 | }); 21 | 22 | // Show dialog immediately if server is not running 23 | useEffect(() => { 24 | if (!isServerRunning) { 25 | showDialog(true); 26 | } 27 | }, [isServerRunning, showDialog]); 28 | 29 | // Effect to store current tab in sessionStorage 30 | useEffect(() => { 31 | sessionStorage.setItem('certManagerTab', currentTab); 32 | }, [currentTab]); 33 | 34 | const certData = { certificates: [] }; // Default or mock data 35 | const isLoading = false; // Default loading status 36 | 37 | const handleBatchDataPackage = () => { 38 | navigate('/data-package'); 39 | }; 40 | 41 | const renderContent = () => ( 42 |
43 |
44 | 45 |
46 | 47 | 51 | Create Certificates 52 | 53 | 57 | Existing Certificates 58 | 59 | 60 |
61 | 62 |
63 | 64 | 65 | 66 | 67 | 72 | 73 |
74 |
75 |
76 |
77 | ); 78 | 79 | return ( 80 | <> 81 | {renderContent()} 82 | 83 | 84 | ); 85 | }; 86 | 87 | export default CertManager; -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/combobox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Check, ChevronsUpDown } from 'lucide-react' 3 | import { cn } from "@/lib/utils" 4 | import { Button } from "@/components/shared/ui/shadcn/button" 5 | import { 6 | Command, 7 | CommandEmpty, 8 | CommandGroup, 9 | CommandInput, 10 | CommandItem, 11 | CommandList, 12 | } from "@/components/shared/ui/shadcn/command" 13 | import { 14 | Popover, 15 | PopoverContent, 16 | PopoverTrigger, 17 | } from "@/components/shared/ui/shadcn/popover" 18 | 19 | interface ComboboxProps { 20 | options: { label: string; value: string }[] 21 | value?: string 22 | onSelect?: (value: string) => void 23 | placeholder?: string 24 | disabled?: boolean 25 | } 26 | 27 | export function Combobox({ options = [], value: externalValue, onSelect, placeholder, disabled = false }: ComboboxProps) { 28 | const [open, setOpen] = React.useState(false) 29 | const [value, setValue] = React.useState(externalValue || "") 30 | 31 | React.useEffect(() => { 32 | setValue(externalValue || "") 33 | }, [externalValue]) 34 | 35 | const safeOptions = Array.isArray(options) ? options : [] 36 | 37 | return ( 38 |
39 | 40 | 41 | 58 | 59 | 60 | 61 | 62 | 63 | No option found. 64 | 65 | {safeOptions.map((option) => ( 66 | { 70 | const newValue = currentValue === value ? "" : currentValue 71 | setValue(newValue) 72 | setOpen(false) 73 | onSelect?.(newValue) 74 | }} 75 | > 76 | 82 |
{option.label}
83 |
84 | ))} 85 |
86 |
87 |
88 |
89 |
90 |
91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /client/src/pages/AdvancedFeatures.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shared/ui/shadcn/tabs"; 3 | import { useTakServerRequired } from '../components/shared/hooks/useTakServerRequired'; 4 | import TakServerRequiredDialog from '../components/shared/TakServerRequiredDialog'; 5 | import CoreConfigEditor from '../components/advancedfeatures/CoreConfigEditor'; 6 | import BackupManager from '../components/advancedfeatures/BackupManager'; 7 | import LogViewer from '../components/advancedfeatures/LogViewer'; 8 | 9 | const AdvancedFeatures: React.FC = () => { 10 | // TAK Server check 11 | const { showDialog, dialogProps, isServerRunning } = useTakServerRequired({ 12 | title: "TAK Server Required for Advanced Features", 13 | description: "Advanced features require TAK Server to be running. Would you like to start it now?", 14 | }); 15 | 16 | const [currentTab, setCurrentTab] = useState(() => { 17 | return sessionStorage.getItem('advancedFeaturesTab') || 'core-config'; 18 | }); 19 | 20 | // Effect to store current tab in sessionStorage 21 | useEffect(() => { 22 | sessionStorage.setItem('advancedFeaturesTab', currentTab); 23 | }, [currentTab]); 24 | 25 | // Show dialog immediately if server is not running and current tab is "logs" 26 | useEffect(() => { 27 | if (!isServerRunning && currentTab === 'logs') { 28 | showDialog(true); 29 | } 30 | }, [isServerRunning, currentTab, showDialog]); 31 | 32 | const renderContent = () => ( 33 |
34 |
35 | 36 |
37 | 38 | 42 | Core Config 43 | 44 | 48 | Backup Manager 49 | 50 | 54 | Logs 55 | 56 | 57 |
58 | 59 |
60 | 61 |
62 | 63 |
64 |
65 | 66 |
67 | 68 |
69 |
70 | 71 |
72 | 73 |
74 |
75 |
76 |
77 |
78 |
79 | ); 80 | 81 | return ( 82 | <> 83 | {renderContent()} 84 | 85 | 86 | ); 87 | }; 88 | 89 | export default AdvancedFeatures; -------------------------------------------------------------------------------- /client/src/components/datapackage/shared/validationSchemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // Zip name validation schema 4 | export const zipNameSchema = z.string() 5 | .min(3, "Zip file name must be at least 3 characters") 6 | .regex(/^[a-zA-Z0-9-_]+$/, "Only letters, numbers, hyphens and underscores are allowed") 7 | .refine(name => !name.includes('.zip'), "Do not include .zip extension"); 8 | 9 | // TAK Server validation schema 10 | export const takServerSchema = z.object({ 11 | description: z.string() 12 | .min(3, "Server name must be at least 3 characters") 13 | .regex(/^[a-zA-Z0-9-]+$/, "Only letters, numbers, and hyphens are allowed") 14 | .nonempty("Server name is required"), 15 | ipAddress: z.string() 16 | .nonempty("IP address or hostname is required") 17 | .refine( 18 | (value) => { 19 | // Check if it's a valid IP address 20 | const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; 21 | 22 | // Check if it's a valid domain name 23 | // This regex allows domain names like example.com, sub.example.com, etc. 24 | const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/i; 25 | 26 | // Check if it's a valid hostname (simple name without dots) 27 | const hostnameRegex = /^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/i; 28 | 29 | return ipRegex.test(value) || domainRegex.test(value) || hostnameRegex.test(value); 30 | }, 31 | { 32 | message: "Must be a valid IP address, domain name, or hostname" 33 | } 34 | ), 35 | port: z.number() 36 | .min(1, "Port must be at least 1") 37 | .max(65535, "Port must be at most 65535"), 38 | protocol: z.enum(["ssl", "tcp"], { 39 | required_error: "Protocol is required", 40 | invalid_type_error: "Protocol must be either SSL or TCP" 41 | }), 42 | caLocation: z.string() 43 | .nonempty("CA certificate is required"), 44 | certPassword: z.string() 45 | .min(8, "Certificate password must be at least 8 characters") 46 | .nonempty("Certificate password is required") 47 | }); 48 | 49 | // ATAK Preference validation schema 50 | export const atakPreferenceSchema = z.object({ 51 | value: z.string(), 52 | enabled: z.boolean(), 53 | input_type: z.enum(["text", "select", "number", "password"]), 54 | options: z.array(z.object({ 55 | value: z.string(), 56 | text: z.string() 57 | })).optional(), 58 | min: z.number().optional(), 59 | max: z.number().optional() 60 | }).refine(data => { 61 | // If preference is enabled, value must not be empty 62 | if (data.enabled && !data.value.trim()) { 63 | return false; 64 | } 65 | 66 | // If preference is enabled and it's a number type, validate number range 67 | if (data.enabled && data.input_type === "number") { 68 | const num = Number(data.value); 69 | if (isNaN(num)) return false; 70 | if (data.min !== undefined && num < data.min) return false; 71 | if (data.max !== undefined && num > data.max) return false; 72 | } 73 | 74 | // If preference is enabled and it's a select type, validate option exists 75 | if (data.enabled && data.input_type === "select" && data.options) { 76 | if (!data.options.some(opt => opt.value === data.value)) { 77 | return false; 78 | } 79 | } 80 | 81 | return true; 82 | }, { 83 | message: "Value is required when preference is enabled" 84 | }); 85 | 86 | // Bulk generation validation schema 87 | export const bulkGenerationSchema = z.object({ 88 | certificates: z.array(z.object({ 89 | value: z.string(), 90 | label: z.string() 91 | })), 92 | fileNames: z.record(z.string(), z.string().regex(/^[a-zA-Z0-9-_]+$/, "Only letters, numbers, hyphens and underscores are allowed")) 93 | }); -------------------------------------------------------------------------------- /README.DEV.md: -------------------------------------------------------------------------------- 1 | # TAK Manager - Development Guide 2 | 3 | This document provides detailed instructions for developers who want to contribute to or modify the TAK Manager application. 4 | 5 | ## Tech Stack 6 | 7 | ### Frontend 8 | - React 19 9 | - TypeScript 10 | - Tailwind CSS 11 | - Shadcn UI components 12 | - Chart.js for data visualization 13 | - Vite for build tooling 14 | 15 | ### Backend 16 | - Python 3.13.2 17 | - FastAPI 18 | - Docker support 19 | - SSE (Server-Sent Events) for real-time updates 20 | - Poetry for python dependency management 21 | 22 | ## Prerequisites 23 | 24 | - Docker and Docker Compose 25 | - Create-dmg (for macOS DMG installers) 26 | - Inno Setup (for Windows EXE installers) 27 | 28 | ## Development Setup 29 | 30 | ### 1. Clone the repository: 31 | ```bash 32 | git clone https://github.com/JShadowNull/TAK-Manager.git 33 | cd Tak-Manager 34 | ``` 35 | 36 | ### 2. Run the setup script: 37 | Before starting the development environment, run the setup script to configure your environment and install dependencies: 38 | ```bash 39 | python dev_scripts/setup_project.py 40 | ``` 41 | This script will: 42 | - Check for necessary system dependencies (like Docker and Docker Compose). 43 | - Create a `.env` file from `.env.example` and set required environment variables. 44 | - Install npm and Python dependencies. 45 | 46 | ### 3. Start the development environment: 47 | ```bash 48 | npm run dev 49 | ``` 50 | This will start the Docker development environment with hot reloading enabled. 51 | 52 | ### 4. For wrapper development (Pywebview app): 53 | ```bash 54 | npm run docker:image-mac # or npm run docker:image-win 55 | ``` 56 | 57 | ```bash 58 | npm run wrapper:dev 59 | ``` 60 | This will start the Pywebview development environment with hot reloading for the wrapper but docker app will need to be built again upon changes. 61 | ## Building Packages 62 | 63 | **Important Note:** Ensure that the root `.env` file is configured for production mode before deploying the application. This includes setting the `MODE` variable to `production` and verifying that all necessary environment variables are correctly defined. 64 | 65 | ### Building for macOS: 66 | 67 | ```bash 68 | npm run docker:image-mac 69 | ``` 70 | 71 | ```bash 72 | npm run package:mac 73 | ``` 74 | This will create a DMG installer in the `TAK-Wrapper/dist` directory. 75 | 76 | ### Building for Windows: 77 | ```bash 78 | npm run docker:image-win 79 | ``` 80 | 81 | ```bash 82 | npm run package:win 83 | ``` 84 | This will create an EXE installer in the `TAK-Wrapper/dist` directory. 85 | 86 | ## Project Structure 87 | 88 | ``` 89 | tak-manager/ 90 | ├── client/ 91 | ├── server/ 92 | ├── TAK-Wrapper/ 93 | │ └── docker/ 94 | │ └── tak-manager-*.tar.gz 95 | │ └── dist/ 96 | │ └── TAK Manager .exe or TAK Manager .dmg 97 | ├── docker-compose.yml 98 | ├── Dockerfile 99 | ├── .env.example 100 | └── .env 101 | ``` 102 | 103 | ## Environment Variables 104 | 105 | Key environment variables that need to be configured: 106 | 107 | - `MODE`: Application mode (development/production) 108 | - `FRONTEND_PORT`: Frontend application port for development 109 | - `BACKEND_PORT`: Backend API port 110 | - `TAK_SERVER_INSTALL_DIR`: TAK Server installation directory on host machine 111 | - `RESTART_POLICY`: Docker container restart policy 112 | - See `.env.example` for all available options 113 | 114 | ## Release Process 115 | 116 | To create a new release: 117 | 118 | ```bash 119 | npm run release 120 | ``` 121 | 122 | This will: 123 | - Update the version number across the application -------------------------------------------------------------------------------- /server/backend/routes/data_package_manager_routes.py: -------------------------------------------------------------------------------- 1 | # backend/routes/data_package_manager_routes.py 2 | 3 | from fastapi import APIRouter, HTTPException 4 | from fastapi.responses import FileResponse 5 | from typing import List 6 | from pydantic import BaseModel 7 | from backend.services.scripts.data_package_config.data_package_manager import DataPackageManager 8 | from backend.config.logging_config import configure_logging 9 | 10 | logger = configure_logging(__name__) 11 | 12 | # Router setup 13 | datapackage_manager = APIRouter() 14 | package_manager = DataPackageManager() 15 | 16 | # Pydantic models 17 | class DeleteRequest(BaseModel): 18 | filenames: List[str] 19 | 20 | @datapackage_manager.get('/list') 21 | async def list_packages(): 22 | """Get all available data packages""" 23 | try: 24 | packages = await package_manager.get_packages() 25 | return { 26 | 'success': True, 27 | 'packages': packages 28 | } 29 | except Exception as e: 30 | error_message = str(e) 31 | logger.error(f"Error getting packages: {error_message}") 32 | raise HTTPException(status_code=500, detail=error_message) 33 | 34 | @datapackage_manager.get('/download/{filename}') 35 | async def download_package(filename: str): 36 | """Download a specific data package""" 37 | try: 38 | file_path = package_manager.get_package_path(filename) 39 | return FileResponse( 40 | file_path, 41 | filename=filename, 42 | media_type='application/zip' 43 | ) 44 | except FileNotFoundError as e: 45 | logger.error(f"Package not found: {filename}") 46 | raise HTTPException(status_code=404, detail=str(e)) 47 | except Exception as e: 48 | error_msg = str(e) 49 | logger.error(f"Error downloading package {filename}: {error_msg}") 50 | raise HTTPException(status_code=500, detail=error_msg) 51 | 52 | @datapackage_manager.delete('/delete/{filename}') 53 | async def delete_package(filename: str): 54 | """Delete a specific data package""" 55 | try: 56 | await package_manager.delete_package(filename) 57 | return {'success': True, 'message': f'Deleted {filename}'} 58 | except FileNotFoundError as e: 59 | logger.error(f"Delete failed - package not found: {filename}") 60 | raise HTTPException(status_code=404, detail=str(e)) 61 | except Exception as e: 62 | error_message = str(e) 63 | logger.error(f"Error deleting package {filename}: {error_message}") 64 | raise HTTPException(status_code=500, detail=error_message) 65 | 66 | @datapackage_manager.delete('/delete') 67 | async def delete_packages(request: DeleteRequest): 68 | """Delete multiple data packages""" 69 | try: 70 | await package_manager.delete_batch(request.filenames) 71 | return { 72 | 'success': True, 73 | 'message': f'Deleted {len(request.filenames)} packages' 74 | } 75 | except Exception as e: 76 | error_message = str(e) 77 | logger.error(f"Error deleting packages: {error_message}") 78 | raise HTTPException(status_code=500, detail=error_message) 79 | 80 | @datapackage_manager.post('/download') 81 | async def download_packages(request: DeleteRequest): 82 | """Download multiple data packages""" 83 | try: 84 | file_paths = await package_manager.download_batch(request.filenames) 85 | return { 86 | 'success': True, 87 | 'file_paths': file_paths, 88 | 'message': f'Ready to download {len(file_paths)} packages' 89 | } 90 | except Exception as e: 91 | error_message = str(e) 92 | logger.error(f"Error downloading packages: {error_message}") 93 | raise HTTPException(status_code=500, detail=error_message) -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { cn } from "@/lib/utils" 4 | 5 | const Dialog = DialogPrimitive.Root 6 | 7 | const DialogTrigger = DialogPrimitive.Trigger 8 | 9 | const DialogPortal = DialogPrimitive.Portal 10 | 11 | const DialogClose = DialogPrimitive.Close 12 | 13 | const DialogOverlay = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, ...props }, ref) => ( 17 | 25 | )) 26 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 27 | 28 | const DialogContent = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ className, children, ...props }, ref) => ( 32 | 33 | 34 | 42 | {children} 43 | 44 | 45 | )) 46 | DialogContent.displayName = DialogPrimitive.Content.displayName 47 | 48 | const DialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | DialogHeader.displayName = "DialogHeader" 61 | 62 | const DialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | DialogFooter.displayName = "DialogFooter" 75 | 76 | const DialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 88 | )) 89 | DialogTitle.displayName = DialogPrimitive.Title.displayName 90 | 91 | const DialogDescription = React.forwardRef< 92 | React.ElementRef, 93 | React.ComponentPropsWithoutRef 94 | >(({ className, ...props }, ref) => ( 95 | 100 | )) 101 | DialogDescription.displayName = DialogPrimitive.Description.displayName 102 | 103 | export { 104 | Dialog, 105 | DialogPortal, 106 | DialogOverlay, 107 | DialogTrigger, 108 | DialogClose, 109 | DialogContent, 110 | DialogHeader, 111 | DialogFooter, 112 | DialogTitle, 113 | DialogDescription, 114 | } -------------------------------------------------------------------------------- /server/backend/services/scripts/data_package_config/data_package_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from typing import List, Dict 4 | from backend.config.logging_config import configure_logging 5 | from backend.services.helpers.directories import DirectoryHelper 6 | 7 | logger = configure_logging(__name__) 8 | 9 | class DataPackageManager: 10 | def __init__(self): 11 | self.directory_helper = DirectoryHelper() 12 | self.packages_dir = self.directory_helper.get_data_packages_directory() 13 | 14 | async def get_packages(self) -> List[Dict[str, str]]: 15 | """List all data packages with their metadata.""" 16 | try: 17 | packages = [] 18 | for filename in os.listdir(self.packages_dir): 19 | if not filename.endswith('.zip'): 20 | logger.error(f"Skipping non-zip file: {filename}") 21 | continue 22 | 23 | file_path = os.path.join(self.packages_dir, filename) 24 | stat = os.stat(file_path) 25 | 26 | size_bytes = stat.st_size 27 | for unit in ['B', 'KB', 'MB', 'GB']: 28 | if size_bytes < 1024: 29 | size = f"{size_bytes:.2f} {unit}" 30 | break 31 | size_bytes /= 1024 32 | 33 | packages.append({ 34 | 'fileName': filename, 35 | 'createdAt': datetime.fromtimestamp(stat.st_mtime).isoformat(), 36 | 'size': size 37 | }) 38 | 39 | return sorted(packages, key=lambda x: x['createdAt'], reverse=True) 40 | 41 | except Exception as e: 42 | logger.error(f"Failed to list packages: {str(e)}") 43 | raise 44 | 45 | def get_package_path(self, filename: str) -> str: 46 | """Validate and return full package path.""" 47 | if not filename.endswith('.zip'): 48 | logger.error(f"Invalid package file extension for: {filename}") 49 | raise ValueError("Invalid package file extension") 50 | 51 | file_path = os.path.join(self.packages_dir, filename) 52 | if not os.path.exists(file_path): 53 | logger.error(f"Package {filename} not found at path: {file_path}") 54 | raise FileNotFoundError(f"Package {filename} not found") 55 | 56 | return file_path 57 | 58 | async def delete_package(self, filename: str) -> None: 59 | """Delete a single package file.""" 60 | try: 61 | file_path = self.get_package_path(filename) 62 | os.remove(file_path) 63 | 64 | if os.path.exists(file_path): 65 | logger.error(f"Failed to delete {filename}, file still exists.") 66 | raise RuntimeError(f"Failed to delete {filename}") 67 | 68 | except Exception as e: 69 | logger.error(f"Package deletion failed for {filename}: {str(e)}") 70 | raise 71 | 72 | async def delete_batch(self, filenames: List[str]) -> None: 73 | """Delete multiple packages in batch.""" 74 | try: 75 | for filename in filenames: 76 | await self.delete_package(filename) 77 | except Exception as e: 78 | logger.error(f"Batch delete failed for files {filenames}: {str(e)}") 79 | raise 80 | 81 | async def download_batch(self, filenames: List[str]) -> List[str]: 82 | """Validate multiple packages for download.""" 83 | try: 84 | return [self.get_package_path(f) for f in filenames] 85 | except Exception as e: 86 | logger.error(f"Download validation failed for files {filenames}: {str(e)}") 87 | raise -------------------------------------------------------------------------------- /client/src/components/shared/TakServerRequiredDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from './ui/shadcn/dialog'; 3 | import { Button } from './ui/shadcn/button'; 4 | import { useTakServer } from './ui/shadcn/sidebar/app-sidebar'; 5 | import { useToast } from '@/components/shared/ui/shadcn/toast/use-toast'; 6 | 7 | interface TakServerRequiredDialogProps { 8 | isOpen: boolean; 9 | onOpenChange: (open: boolean) => void; 10 | title?: string; 11 | description?: string; 12 | } 13 | 14 | const TakServerRequiredDialog: React.FC = ({ 15 | isOpen, 16 | onOpenChange, 17 | title = "TAK Server Required", 18 | description = "This feature requires TAK Server to be running. Would you like to start it now?" 19 | }) => { 20 | const { serverState, refreshServerStatus } = useTakServer(); 21 | const { toast } = useToast(); 22 | const [currentOperation, setCurrentOperation] = useState<'start' | null>(null); 23 | const [error, setError] = useState(null); 24 | 25 | // Reset state when dialog opens/closes 26 | useEffect(() => { 27 | if (!isOpen) { 28 | setCurrentOperation(null); 29 | setError(null); 30 | } 31 | }, [isOpen]); 32 | 33 | // Close dialog if server becomes running 34 | useEffect(() => { 35 | if (serverState?.isRunning) { 36 | onOpenChange(false); 37 | } 38 | }, [serverState?.isRunning, onOpenChange]); 39 | 40 | const handleStart = async () => { 41 | try { 42 | setError(null); 43 | setCurrentOperation('start'); 44 | 45 | const response = await fetch('/api/takserver/start-takserver', { 46 | method: 'POST' 47 | }); 48 | 49 | if (!response.ok) { 50 | const errorData = await response.json(); 51 | throw new Error(errorData.detail || `Operation failed: ${response.statusText}`); 52 | } 53 | 54 | // Force immediate status refresh 55 | await refreshServerStatus(); 56 | 57 | toast({ 58 | title: "Server Starting", 59 | description: "TAK Server Started Successfully", 60 | variant: "success" 61 | }); 62 | } catch (error) { 63 | const errorMessage = error instanceof Error ? error.message : 'Operation failed'; 64 | setError(errorMessage); 65 | toast({ 66 | title: "Start Failed", 67 | description: errorMessage, 68 | variant: "destructive" 69 | }); 70 | 71 | // Refresh status even on error to ensure UI consistency 72 | await refreshServerStatus(); 73 | } finally { 74 | setCurrentOperation(null); 75 | } 76 | }; 77 | 78 | return ( 79 | 80 | 81 | 82 | {title} 83 | {description} 84 | 85 | 86 | {error && ( 87 |

{error}

88 | )} 89 | 90 | 91 | 98 | 105 | 106 |
107 |
108 | ); 109 | }; 110 | 111 | export default TakServerRequiredDialog; -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # configuration file for git-cliff 2 | [changelog] 3 | # changelog header 4 | header = """ 5 | # Changelog\n 6 | All notable changes to this project will be documented in this file.\n 7 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 8 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n 9 | """ 10 | # template for the changelog body 11 | # https://tera.netlify.app/docs 12 | body = """ 13 | {% if version %}\ 14 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 15 | {% endif %}\ 16 | {% for group, commits in commits | group_by(attribute="group") %} 17 | 18 | ### {{ group | upper_first }} {% if group == "added" %}🚀{% elif group == "changed" %}🔄{% elif group == "deprecated" %}⚠️{% elif group == "removed" %}🗑️{% elif group == "fixed" %}🔧{% elif group == "security" %}🔒{% elif group == "performance" %}⚡{% else %}📦{% endif %} 19 | 20 | {% for commit in commits | unique(attribute="message") %} 21 | - {{ commit.message | upper_first }}{% if commit.breaking %} ⚠️ BREAKING CHANGE{% endif %} 22 | {%- endfor %} 23 | {% endfor %}\n 24 | """ 25 | # remove the leading and trailing whitespace from the template 26 | trim = true 27 | # postprocessors 28 | postprocessors = [ 29 | { pattern = '\$([a-zA-Z_][0-9a-zA-Z_]*)', replace = "\\${{$1}}" }, # escape variables 30 | ] 31 | 32 | # git-cliff preprocessors 33 | [git] 34 | # parse the conventional commits 35 | conventional_commits = true 36 | # filter out the commits that are not conventional 37 | filter_unconventional = false 38 | # process each line of a commit as an individual commit 39 | split_commits = false 40 | # regex for preprocessing the commit messages 41 | commit_preprocessors = [ 42 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://gitea.local.ubuntuserver.buzz/Jake/Tak-Manager/issues/${2}))" }, 43 | ] 44 | # regex for parsing and grouping commits 45 | commit_parsers = [ 46 | { message = "^feat", group = "added" }, 47 | { message = "^feature", group = "added" }, 48 | { message = "^add", group = "added" }, 49 | { message = "^fix", group = "fixed" }, 50 | { message = "^bugfix", group = "fixed" }, 51 | { message = "^perf", group = "performance" }, 52 | { message = "^performance", group = "performance" }, 53 | { message = "^change", group = "changed" }, 54 | { message = "^refactor", group = "changed" }, 55 | { message = "^style", group = "changed" }, 56 | { message = "^revert", group = "changed" }, 57 | { message = "^doc", group = "documentation" }, 58 | { message = "^docs", group = "documentation" }, 59 | { message = "^test", group = "testing" }, 60 | { message = "^tests", group = "testing" }, 61 | { message = "^chore\\(release\\): ", skip = true }, 62 | { message = "^chore\\(deps\\)", group = "dependencies" }, 63 | { message = "^chore", group = "other" }, 64 | { message = "^ci", group = "other" }, 65 | { message = "^build", group = "other" }, 66 | { body = ".*security", group = "security" }, 67 | { message = ".*deprecated", group = "deprecated" }, 68 | { message = "^remove", group = "removed" }, 69 | { message = "^delete", group = "removed" }, 70 | { message = ".*", group = "Other", default_scope = "other"}, 71 | ] 72 | # protect breaking changes from being skipped due to matching a skipped commit_parser 73 | protect_breaking_commits = false 74 | # filter out the commits that are not matched by commit parsers 75 | filter_commits = true 76 | # glob pattern for matching git tags 77 | tag_pattern = "v[0-9]*" 78 | # regex for skipping tags 79 | skip_tags = "v0.1.0-beta.1" 80 | # regex for ignoring tags 81 | ignore_tags = "" 82 | # sort the tags topologically 83 | topo_order = false 84 | # sort the commits inside sections by oldest/newest order 85 | sort_commits = "oldest" 86 | # limit the number of commits included in the changelog. 87 | # limit_commits = 200 -------------------------------------------------------------------------------- /server/backend/__init__.py: -------------------------------------------------------------------------------- 1 | # backend/__init__.py 2 | import os 3 | from fastapi import FastAPI, HTTPException 4 | from fastapi.staticfiles import StaticFiles 5 | from fastapi.responses import FileResponse 6 | from backend.config.logging_config import configure_logging 7 | from starlette.middleware.base import BaseHTTPMiddleware 8 | 9 | # Define middleware for large file uploads 10 | class LargeRequestMiddleware(BaseHTTPMiddleware): 11 | async def dispatch(self, request, call_next): 12 | # Increase upload limit to 10GB 13 | request._body_size_limit = 10 * 1024 * 1024 * 1024 # 10GB 14 | response = await call_next(request) 15 | return response 16 | 17 | # Import routers (equivalent to blueprints) 18 | from backend.routes.dashboard_routes import dashboard 19 | from backend.routes.docker_manager_routes import dockermanager 20 | from backend.routes.data_package_route import datapackage 21 | from backend.routes.data_package_manager_routes import datapackage_manager 22 | from backend.routes.takserver_routes import takserver 23 | from backend.routes.ota_routes import ota 24 | from backend.routes.certmanager_routes import certmanager 25 | from backend.routes.advanced_features_routes import advanced_features 26 | from backend.routes.port_manager_routes import portmanager 27 | from backend.routes.takserver_api_routes import takserver_api 28 | 29 | def create_app(): 30 | # Set up logging 31 | logger = configure_logging(__name__) 32 | logger.info("Creating FastAPI application") 33 | 34 | # Determine mode 35 | is_dev = os.environ.get('MODE', 'development') == 'development' 36 | 37 | # Initialize FastAPI app 38 | app = FastAPI( 39 | title="TAK Manager API", 40 | debug=is_dev 41 | ) 42 | 43 | # Add middleware for large file uploads 44 | app.add_middleware(LargeRequestMiddleware) 45 | 46 | # Health check endpoint 47 | @app.get('/health') 48 | async def health_check(): 49 | return {'status': 'healthy'} 50 | 51 | # Include routers (equivalent to registering blueprints) FIRST 52 | app.include_router(dashboard, prefix='/api/dashboard') 53 | app.include_router(dockermanager, prefix='/api/docker-manager') 54 | app.include_router(datapackage, prefix='/api/datapackage') 55 | app.include_router(datapackage_manager, prefix='/api/datapackage') 56 | app.include_router(takserver, prefix='/api/takserver') 57 | app.include_router(ota, prefix='/api/ota') 58 | app.include_router(certmanager, prefix='/api/certmanager') 59 | app.include_router(advanced_features, prefix='/api/advanced') 60 | app.include_router(portmanager, prefix='/api/port-manager') 61 | app.include_router(takserver_api, prefix='/api/takserver-api') 62 | 63 | # Only serve static files in production mode AFTER API routes 64 | if not is_dev: 65 | client_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "client")) 66 | build_dir = os.path.join(client_dir, 'build') 67 | app.mount("/static", StaticFiles(directory=build_dir), name="static") 68 | 69 | @app.get('/') 70 | async def serve_root(): 71 | return FileResponse(os.path.join(build_dir, 'index.html')) 72 | 73 | @app.get('/{full_path:path}') 74 | async def serve_react(full_path: str): 75 | # Don't intercept API routes 76 | if full_path.startswith('api/'): 77 | raise HTTPException(status_code=404, detail="API route not found") 78 | 79 | file_path = os.path.join(build_dir, full_path) 80 | if os.path.exists(file_path) and os.path.isfile(file_path): 81 | return FileResponse(file_path) 82 | logger.debug(f"File not found: {file_path}") # Changed from error to debug level 83 | return FileResponse(os.path.join(build_dir, 'index.html')) 84 | 85 | logger.info(f"FastAPI application created in {'development' if is_dev else 'production'} mode") 86 | return app 87 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | interface ScrollAreaProps extends React.ComponentPropsWithoutRef { 7 | autoScroll?: boolean; 8 | content?: any; // Content to watch for changes 9 | flexHeight?: boolean; // Whether to use flexible height based on content 10 | maxHeight?: string; // Optional max height for flex mode 11 | } 12 | 13 | const ScrollArea = React.forwardRef< 14 | React.ElementRef, 15 | ScrollAreaProps 16 | >(({ className, children, autoScroll = false, content, flexHeight = false, maxHeight, ...props }, ref) => { 17 | const viewportRef = React.useRef(null); 18 | 19 | const scrollToBottom = React.useCallback(() => { 20 | if (viewportRef.current) { 21 | const scrollContainer = viewportRef.current; 22 | scrollContainer.scrollTop = scrollContainer.scrollHeight; 23 | } 24 | }, []); 25 | 26 | // Scroll on content change 27 | React.useEffect(() => { 28 | if (!autoScroll || !viewportRef.current) return; 29 | 30 | // Immediate scroll 31 | scrollToBottom(); 32 | 33 | // Delayed scroll to handle dynamic content 34 | const timeoutId = setTimeout(scrollToBottom, 100); 35 | return () => clearTimeout(timeoutId); 36 | }, [autoScroll, content, children, scrollToBottom]); 37 | 38 | // Set up resize observer to handle dynamic height changes 39 | React.useEffect(() => { 40 | if (!autoScroll || !viewportRef.current) return; 41 | 42 | const resizeObserver = new ResizeObserver(() => { 43 | scrollToBottom(); 44 | }); 45 | 46 | resizeObserver.observe(viewportRef.current); 47 | return () => resizeObserver.disconnect(); 48 | }, [autoScroll, scrollToBottom]); 49 | 50 | // For flexHeight mode, we need to use a different approach 51 | if (flexHeight) { 52 | return ( 53 |
58 | {children} 59 |
60 | ); 61 | } 62 | 63 | // Standard ScrollArea with fixed height 64 | return ( 65 | 70 | 78 | {children} 79 | 80 | 81 | 82 | 83 | ); 84 | }) 85 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 86 | 87 | const ScrollBar = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, orientation = "vertical", ...props }, ref) => ( 91 | 104 | 105 | 106 | )) 107 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 108 | 109 | export { ScrollArea, ScrollBar } 110 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tak-manager-app", 3 | "productName": "Tak Manager", 4 | "version": "3.1.2", 5 | "description": "This is a modular Flask-based application that monitors CPU, RAM usage, and running services. It provides a simple interface for viewing system metrics using a Flask backend and a PyWebview frontend.", 6 | "type": "module", 7 | "main": "src/index.tsx", 8 | "engines": { 9 | "node": ">=20.0.0", 10 | "npm": ">=10.0.0" 11 | }, 12 | "scripts": { 13 | "start": "vite", 14 | "build": "tsc && vite build", 15 | "image:mac": "rm -rf ../TAK-Wrapper/docker/* && docker compose -f ../docker-compose.yml build prod && mkdir -p ../TAK-Wrapper/docker && docker save tak-manager:${npm_package_version} | gzip > ../TAK-Wrapper/docker/tak-manager-${npm_package_version}.tar.gz", 16 | "image:win": "docker compose -f ../docker-compose.yml build prod && if not exist ..\\TAK-Wrapper\\docker mkdir ..\\TAK-Wrapper\\docker && docker save tak-manager:%npm_package_version% -o ..\\TAK-Wrapper\\docker\\tak-manager-%npm_package_version%.tar", 17 | "docker:dev": "docker compose -f ../docker-compose.yml up dev --build" 18 | }, 19 | "keywords": [], 20 | "author": "", 21 | "license": "ISC", 22 | "dependencies": { 23 | "@codemirror/autocomplete": "^6.18.6", 24 | "@codemirror/commands": "^6.8.0", 25 | "@codemirror/highlight": "^0.19.8", 26 | "@codemirror/lang-xml": "^6.1.0", 27 | "@codemirror/language": "^6.10.8", 28 | "@codemirror/lint": "^6.8.4", 29 | "@codemirror/search": "^6.5.9", 30 | "@codemirror/state": "^6.5.2", 31 | "@codemirror/theme-one-dark": "^6.1.2", 32 | "@codemirror/view": "^6.36.3", 33 | "@emotion/react": "^11.14.0", 34 | "@emotion/styled": "^11.14.0", 35 | "@fontsource/material-icons": "^5.1.1", 36 | "@fontsource/roboto": "^5.1.1", 37 | "@fortawesome/fontawesome-svg-core": "^6.7.2", 38 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 39 | "@fortawesome/react-fontawesome": "^0.2.2", 40 | "@hookform/resolvers": "^4.1.0", 41 | "@mui/icons-material": "^6.4.5", 42 | "@mui/material": "^6.4.5", 43 | "@mui/styled-engine": "^6.4.3", 44 | "@radix-ui/react-checkbox": "^1.1.4", 45 | "@radix-ui/react-dialog": "^1.1.6", 46 | "@radix-ui/react-label": "^2.1.2", 47 | "@radix-ui/react-navigation-menu": "^1.2.5", 48 | "@radix-ui/react-popover": "^1.1.6", 49 | "@radix-ui/react-progress": "^1.1.2", 50 | "@radix-ui/react-scroll-area": "^1.2.3", 51 | "@radix-ui/react-separator": "^1.1.2", 52 | "@radix-ui/react-slot": "^1.1.2", 53 | "@radix-ui/react-switch": "^1.1.3", 54 | "@radix-ui/react-tabs": "^1.1.3", 55 | "@radix-ui/react-toast": "^1.2.6", 56 | "@radix-ui/react-tooltip": "^1.1.8", 57 | "@tailwindcss/vite": "^4.0.7", 58 | "@types/recharts": "^1.8.29", 59 | "@uiw/react-codemirror": "^4.23.8", 60 | "chart.js": "^4.4.8", 61 | "chartjs-adapter-date-fns": "^3.0.0", 62 | "class-variance-authority": "^0.7.1", 63 | "clsx": "^2.1.1", 64 | "cmdk": "^1.0.4", 65 | "date-fns": "^4.1.0", 66 | "lucide-react": "^0.475.0", 67 | "react": "^19.0.0", 68 | "react-chartjs-2": "^5.3.0", 69 | "react-dom": "^19.0.0", 70 | "react-dropzone": "^14.3.5", 71 | "react-hook-form": "^7.54.2", 72 | "react-router-dom": "^7.2.0", 73 | "recharts": "^2.15.1", 74 | "shadcn-ui": "^0.9.4", 75 | "tailwind-merge": "^3.0.1", 76 | "tailwindcss": "^4.0.7", 77 | "tailwindcss-animate": "^1.0.7", 78 | "zod": "^3.24.2" 79 | }, 80 | "devDependencies": { 81 | "@types/debug": "^4.1.12", 82 | "@types/http-cache-semantics": "^4.0.4", 83 | "@types/ms": "^2.1.0", 84 | "@types/node": "^22.13.4", 85 | "@types/react": "^19.0.10", 86 | "@types/react-dom": "^19.0.4", 87 | "@types/responselike": "^1.0.3", 88 | "@types/verror": "^1.10.10", 89 | "@types/yauzl": "^2.10.3", 90 | "@vitejs/plugin-react": "^4.3.4", 91 | "concurrently": "^9.1.2", 92 | "typescript": "^5.7.3", 93 | "vite": "^6.1.1", 94 | "vite-plugin-monaco-editor": "^1.1.0", 95 | "vite-plugin-pwa": "^0.21.1", 96 | "workbox-window": "^7.3.0" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react" 2 | 3 | type Theme = "dark" | "light" | "system" 4 | 5 | type ThemeProviderProps = { 6 | children: React.ReactNode 7 | defaultTheme?: Theme 8 | storageKey?: string 9 | } 10 | 11 | type ThemeProviderState = { 12 | theme: Theme 13 | setTheme: (theme: Theme) => void 14 | } 15 | 16 | const initialState: ThemeProviderState = { 17 | theme: "system", 18 | setTheme: () => null, 19 | } 20 | 21 | const ThemeProviderContext = createContext(initialState) 22 | 23 | // Theme color values from tailwind.css 24 | const THEME_COLORS = { 25 | light: "hsl(0 0% 100%)", 26 | dark: "hsl(240 10% 3.9%)" 27 | } 28 | 29 | // Helper to get the actual theme (accounting for system preference) 30 | export function resolveTheme(theme: Theme): "dark" | "light" { 31 | if (theme === "system") { 32 | return window.matchMedia("(prefers-color-scheme: dark)").matches 33 | ? "dark" 34 | : "light" 35 | } 36 | return theme 37 | } 38 | 39 | export function ThemeProvider({ 40 | children, 41 | defaultTheme = "system", 42 | storageKey = "vite-ui-theme", 43 | ...props 44 | }: ThemeProviderProps) { 45 | const [theme, setTheme] = useState( 46 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme 47 | ) 48 | 49 | useEffect(() => { 50 | const root = window.document.documentElement 51 | const themeColorMeta = document.querySelector('meta[name="theme-color"]') 52 | 53 | root.classList.remove("light", "dark") 54 | 55 | let appliedTheme = resolveTheme(theme) 56 | 57 | root.classList.add(appliedTheme) 58 | 59 | // Update the theme-color meta tag 60 | if (themeColorMeta) { 61 | const themeColor = THEME_COLORS[appliedTheme] 62 | themeColorMeta.setAttribute("content", themeColor) 63 | } 64 | 65 | // Add listener for system theme changes 66 | if (theme === "system") { 67 | const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") 68 | 69 | const handleChange = (e: MediaQueryListEvent) => { 70 | const newTheme = e.matches ? "dark" : "light" 71 | root.classList.remove("light", "dark") 72 | root.classList.add(newTheme) 73 | 74 | if (themeColorMeta) { 75 | const themeColor = THEME_COLORS[newTheme] 76 | themeColorMeta.setAttribute("content", themeColor) 77 | } 78 | } 79 | 80 | mediaQuery.addEventListener("change", handleChange) 81 | 82 | return () => { 83 | mediaQuery.removeEventListener("change", handleChange) 84 | } 85 | } 86 | }, [theme]) 87 | 88 | const value = { 89 | theme, 90 | setTheme: (theme: Theme) => { 91 | localStorage.setItem(storageKey, theme) 92 | setTheme(theme) 93 | }, 94 | } 95 | 96 | return ( 97 | 98 | {children} 99 | 100 | ) 101 | } 102 | 103 | export const useTheme = () => { 104 | const context = useContext(ThemeProviderContext) 105 | 106 | if (context === undefined) 107 | throw new Error("useTheme must be used within a ThemeProvider") 108 | 109 | return context 110 | } 111 | 112 | export const useThemeColor = () => { 113 | const { theme } = useTheme() 114 | const [themeColor, setThemeColor] = useState(() => { 115 | const resolvedTheme = resolveTheme(theme) 116 | return THEME_COLORS[resolvedTheme] 117 | }) 118 | 119 | useEffect(() => { 120 | const updateThemeColor = () => { 121 | const resolvedTheme = resolveTheme(theme) 122 | setThemeColor(THEME_COLORS[resolvedTheme]) 123 | } 124 | 125 | updateThemeColor() 126 | 127 | if (theme === "system") { 128 | const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") 129 | mediaQuery.addEventListener("change", updateThemeColor) 130 | return () => mediaQuery.removeEventListener("change", updateThemeColor) 131 | } 132 | }, [theme]) 133 | 134 | return themeColor 135 | } 136 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'path' 4 | import { VitePWA } from 'vite-plugin-pwa' 5 | import tailwindcss from '@tailwindcss/vite' 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | react(), 11 | tailwindcss(), 12 | VitePWA({ 13 | registerType: 'prompt', 14 | includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'maskable-icon-512x512.png'], 15 | injectRegister: 'auto', 16 | workbox: { 17 | globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,json}'], 18 | cleanupOutdatedCaches: true, 19 | skipWaiting: true, 20 | clientsClaim: true, 21 | runtimeCaching: [ 22 | { 23 | urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, 24 | handler: 'CacheFirst', 25 | options: { 26 | cacheName: 'google-fonts-cache', 27 | expiration: { 28 | maxEntries: 10, 29 | maxAgeSeconds: 60 * 60 * 24 * 365 // <== 365 days 30 | }, 31 | cacheableResponse: { 32 | statuses: [0, 200] 33 | } 34 | } 35 | } 36 | ] 37 | }, 38 | devOptions: { 39 | enabled: true, 40 | type: 'module' 41 | }, 42 | manifest: { 43 | name: 'TAK Manager', 44 | short_name: 'TAK Manager', 45 | description: 'A modular application for managing TAK Server instances', 46 | theme_color: '#09090B', 47 | background_color: '#09090B', 48 | display: 'standalone', 49 | orientation: 'portrait', 50 | categories: ['utilities', 'productivity'], 51 | scope: '/', 52 | start_url: '/', 53 | id: 'takmanager', 54 | lang: 'en', 55 | dir: 'ltr', 56 | prefer_related_applications: false, 57 | icons: [ 58 | { 59 | src: 'pwa-64x64.png', 60 | sizes: '64x64', 61 | type: 'image/png', 62 | purpose: 'any' 63 | }, 64 | { 65 | src: 'pwa-192x192.png', 66 | sizes: '192x192', 67 | type: 'image/png', 68 | purpose: 'any' 69 | }, 70 | { 71 | src: 'pwa-512x512.png', 72 | sizes: '512x512', 73 | type: 'image/png', 74 | purpose: 'any' 75 | }, 76 | { 77 | src: 'maskable-icon-512x512.png', 78 | sizes: '512x512', 79 | type: 'image/png', 80 | purpose: 'maskable' 81 | } 82 | ], 83 | shortcuts: [ 84 | { 85 | name: 'Dashboard', 86 | short_name: 'Dashboard', 87 | description: 'View TAK Server Dashboard', 88 | url: '/', 89 | icons: [ 90 | { 91 | src: 'pwa-192x192.png', 92 | sizes: '192x192', 93 | type: 'image/png' 94 | } 95 | ] 96 | } 97 | ] 98 | } 99 | }) 100 | ] as any, 101 | root: '.', 102 | build: { 103 | outDir: 'build', 104 | sourcemap: true, 105 | chunkSizeWarningLimit: 1000, // Increase chunk size limit to 1000kb 106 | rollupOptions: { 107 | output: { 108 | manualChunks: { 109 | vendor: ['react', 'react-dom'], 110 | ui: [ 111 | '@radix-ui/react-dialog', 112 | '@radix-ui/react-popover', 113 | '@radix-ui/react-tooltip', 114 | '@mui/material', 115 | '@emotion/react', 116 | '@emotion/styled' 117 | ] 118 | } 119 | } 120 | } 121 | }, 122 | resolve: { 123 | alias: { 124 | '@': path.resolve(__dirname, 'src'), 125 | }, 126 | }, 127 | server: { 128 | host: '0.0.0.0', 129 | port: parseInt(process.env.FRONTEND_PORT as string), 130 | allowedHosts: ['takserver-dev'], 131 | proxy: { 132 | '/api': `http://127.0.0.1:${process.env.BACKEND_PORT}`, 133 | '/stream': `http://127.0.0.1:${process.env.BACKEND_PORT}`, 134 | } 135 | } 136 | }) -------------------------------------------------------------------------------- /server/backend/services/scripts/data_package_config/modules/custom_files.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import aiofiles 4 | import mimetypes 5 | import datetime 6 | from fastapi import UploadFile 7 | from backend.config.logging_config import configure_logging 8 | 9 | logger = configure_logging(__name__) 10 | 11 | class CustomFilesManager: 12 | def __init__(self, directory_helper): 13 | self.directory_helper = directory_helper 14 | 15 | def get_custom_files_directory(self): 16 | """Get the directory for storing custom files""" 17 | custom_dir = os.path.join(self.directory_helper.get_data_packages_directory(), 'customfiles') 18 | os.makedirs(custom_dir, exist_ok=True) 19 | return custom_dir 20 | 21 | async def copy_custom_files(self, temp_dir, custom_files): 22 | """Copy custom files to temp directory at root level""" 23 | try: 24 | logger.debug(f"[copy_custom_files] Starting to copy files: {custom_files}") 25 | custom_dir = self.get_custom_files_directory() 26 | logger.debug(f"[copy_custom_files] Custom files directory: {custom_dir}") 27 | 28 | for filename in custom_files: 29 | src = os.path.join(custom_dir, filename) 30 | dest = os.path.join(temp_dir, os.path.basename(filename)) 31 | logger.debug(f"[copy_custom_files] Copying {src} -> {dest}") 32 | 33 | if os.path.exists(src): 34 | shutil.copy2(src, dest) 35 | logger.debug(f"[copy_custom_files] Successfully copied {filename}") 36 | else: 37 | logger.warning(f"[copy_custom_files] File not found: {src}") 38 | except Exception as e: 39 | logger.error(f"[copy_custom_files] Failed to copy files: {str(e)}") 40 | logger.error("Temporary directory: %s", temp_dir) # Log temp directory for debugging 41 | logger.error("Custom files: %s", custom_files) # Log custom files for debugging 42 | raise 43 | 44 | async def list_custom_files(self) -> list: 45 | """List all uploaded custom files""" 46 | custom_dir = self.get_custom_files_directory() 47 | return [f for f in os.listdir(custom_dir) if os.path.isfile(os.path.join(custom_dir, f))] 48 | 49 | async def list_custom_files_with_metadata(self) -> list: 50 | """List all uploaded custom files with metadata""" 51 | custom_dir = self.get_custom_files_directory() 52 | files = [] 53 | 54 | for filename in os.listdir(custom_dir): 55 | file_path = os.path.join(custom_dir, filename) 56 | if os.path.isfile(file_path): 57 | try: 58 | stat = os.stat(file_path) 59 | mime_type, _ = mimetypes.guess_type(filename) 60 | 61 | files.append({ 62 | 'name': filename, 63 | 'size': stat.st_size, 64 | 'type': mime_type or 'application/octet-stream', 65 | 'lastModified': datetime.datetime.fromtimestamp(stat.st_mtime).isoformat() 66 | }) 67 | except Exception as e: 68 | logger.error(f"Error getting metadata for {filename}: {str(e)}") 69 | continue 70 | 71 | return files 72 | 73 | async def save_custom_file(self, file: UploadFile): 74 | """Save an uploaded custom file""" 75 | custom_dir = self.get_custom_files_directory() 76 | file_path = os.path.join(custom_dir, file.filename) 77 | 78 | async with aiofiles.open(file_path, 'wb') as f: 79 | # Use larger chunks (8MB) for better performance with large files 80 | chunk_size = 8 * 1024 * 1024 # 8MB chunks 81 | while True: 82 | chunk = await file.read(chunk_size) 83 | if not chunk: 84 | break 85 | await f.write(chunk) 86 | 87 | async def delete_custom_file(self, filename: str): 88 | """Delete a custom file from the server""" 89 | custom_dir = self.get_custom_files_directory() 90 | file_path = os.path.join(custom_dir, filename) 91 | 92 | if os.path.exists(file_path): 93 | os.remove(file_path) 94 | else: 95 | logger.error(f"File {filename} not found for deletion") 96 | raise FileNotFoundError(f"File {filename} not found") -------------------------------------------------------------------------------- /client/src/components/datapackage/CotStreamsSection/cotStreamConfig.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { takServerSchema } from '../shared/validationSchemas'; 3 | 4 | export interface CotStreamItem { 5 | name: string; 6 | label: string; 7 | input_type: 'text' | 'select' | 'number' | 'password'; 8 | value?: string; 9 | defaultValue?: string; 10 | options?: Array<{ value: string; text: string }>; 11 | placeholder?: string; 12 | min?: number; 13 | max?: number; 14 | required?: boolean; 15 | isCertificateDropdown?: boolean; 16 | } 17 | 18 | export const generateCotStreamItems = (count: number): CotStreamItem[] => { 19 | const baseItems: CotStreamItem[] = [{ 20 | name: "Number of CoT (Cursor on Target) streams configured", 21 | label: "count", 22 | input_type: "number", 23 | value: count.toString(), 24 | min: 1, 25 | max: 10, 26 | required: true 27 | }]; 28 | 29 | for (let i = 0; i < count; i++) { 30 | baseItems.push( 31 | { 32 | name: `Name for TAK Server`, 33 | label: `description${i}`, 34 | input_type: "text", 35 | placeholder: "My-Server-Name", 36 | required: true 37 | }, 38 | { 39 | name: `IP address, domain name, or hostname of TAK Server`, 40 | label: `ipAddress${i}`, 41 | input_type: "text", 42 | placeholder: "192.168.1.20, example.com, or takserver", 43 | required: true 44 | }, 45 | { 46 | name: `Port of TAK Server`, 47 | label: `port${i}`, 48 | input_type: "number", 49 | placeholder: "8089", 50 | min: 1, 51 | max: 65535, 52 | required: true 53 | }, 54 | { 55 | name: `Protocol of TAK Server`, 56 | label: `protocol${i}`, 57 | input_type: "select", 58 | options: [ 59 | { value: "ssl", text: "SSL" }, 60 | { value: "tcp", text: "TCP" } 61 | ], 62 | placeholder: "Select protocol", 63 | required: true, 64 | value: "ssl" 65 | }, 66 | { 67 | name: `CA certificate for TAK Server`, 68 | label: `caLocation${i}`, 69 | input_type: "select", 70 | options: [], 71 | placeholder: "Select CA certificate", 72 | isCertificateDropdown: true, 73 | required: true 74 | }, 75 | { 76 | name: `Certificate password`, 77 | label: `certPassword${i}`, 78 | input_type: "password", 79 | placeholder: "atakatak", 80 | required: true 81 | } 82 | ); 83 | } 84 | return baseItems; 85 | }; 86 | 87 | export const validateCotStreams = (preferences: Record) => { 88 | const errors: Record = {}; 89 | const count = parseInt(preferences.count?.value || "1", 10); 90 | 91 | for (let i = 0; i < count; i++) { 92 | // Get all values, ensuring empty strings for undefined values 93 | const description = preferences[`description${i}`]?.value ?? ""; 94 | const ipAddress = preferences[`ipAddress${i}`]?.value ?? ""; 95 | const portStr = preferences[`port${i}`]?.value ?? ""; 96 | const protocol = preferences[`protocol${i}`]?.value ?? ""; 97 | const caLocation = preferences[`caLocation${i}`]?.value ?? ""; 98 | const certPassword = preferences[`certPassword${i}`]?.value ?? ""; 99 | 100 | // Debug logging 101 | console.debug('[validateCotStreams] Stream data before validation:', { 102 | index: i, 103 | description, 104 | ipAddress, 105 | portStr, 106 | protocol, 107 | caLocation, 108 | certPassword 109 | }); 110 | 111 | // Convert port to number, ensuring 0 for invalid values 112 | const port = parseInt(portStr) || 0; 113 | 114 | const streamData = { 115 | description, 116 | ipAddress, 117 | port, 118 | protocol, 119 | caLocation, 120 | certPassword 121 | }; 122 | 123 | try { 124 | console.debug('[validateCotStreams] Attempting validation for stream:', i, streamData); 125 | takServerSchema.parse(streamData); 126 | console.debug('[validateCotStreams] Validation successful for stream:', i); 127 | } catch (error) { 128 | if (error instanceof z.ZodError) { 129 | console.debug('[validateCotStreams] Validation errors:', error.errors); 130 | error.errors.forEach(err => { 131 | const field = err.path[0] as string; 132 | errors[`${field}${i}`] = err.message; 133 | }); 134 | } 135 | } 136 | } 137 | 138 | console.debug('[validateCotStreams] Final validation errors:', errors); 139 | return errors; 140 | }; -------------------------------------------------------------------------------- /server/backend/services/scripts/data_package_config/modules/package_creator.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | import shutil 4 | from backend.config.logging_config import configure_logging 5 | 6 | logger = configure_logging(__name__) 7 | 8 | class PackageCreator: 9 | def __init__(self, directory_helper): 10 | self.directory_helper = directory_helper 11 | 12 | def create_manifest_file(self, temp_dir, zip_name, ca_certs, client_certs, custom_files): 13 | """Updated manifest creation with custom files at root level""" 14 | try: 15 | manifest_dir = os.path.join(temp_dir, 'MANIFEST') 16 | os.makedirs(manifest_dir, exist_ok=True) 17 | 18 | # Generate a random UUID 19 | package_uid = str(uuid.uuid4()) 20 | 21 | # Start building the manifest content 22 | manifest_content = f""" 23 | 24 | 25 | 26 | 27 | 28 | 29 | """ 30 | 31 | # Add all CA certificates first 32 | if ca_certs: 33 | for ca_cert in ca_certs: 34 | manifest_content += f'\n ' 35 | 36 | # Then add all client certificates (if any) 37 | if client_certs: 38 | for client_cert in client_certs: 39 | manifest_content += f'\n ' 40 | 41 | # Add custom files at root level 42 | if custom_files: 43 | for custom_file in custom_files: 44 | manifest_content += f'\n ' 45 | 46 | # Always add the initial.pref entry last 47 | manifest_content += f'\n ' 48 | 49 | # Close the manifest content 50 | manifest_content += "\n \n" 51 | 52 | manifest_path = os.path.join(manifest_dir, 'manifest.xml') 53 | with open(manifest_path, 'w', encoding='utf-8') as f: 54 | f.write(manifest_content) 55 | 56 | # Log the generated manifest for debugging 57 | logger.debug(f"Generated manifest:\n{manifest_content}") 58 | return manifest_path 59 | except Exception as e: 60 | logger.error(f"Manifest creation failed: {str(e)}") 61 | logger.error("Temporary directory: %s", temp_dir) # Log temp directory for debugging 62 | logger.error("Zip name: %s", zip_name) # Log zip name for debugging 63 | logger.error("CA certificates: %s", ca_certs) # Log CA certificates for debugging 64 | logger.error("Client certificates: %s", client_certs) # Log client certificates for debugging 65 | logger.error("Custom files: %s", custom_files) # Log custom files for debugging 66 | raise Exception(f"Manifest file creation error: {str(e)}") 67 | 68 | async def create_zip_file(self, temp_dir, zip_name): 69 | """Creates final zip package""" 70 | try: 71 | packages_dir = self.directory_helper.get_data_packages_directory() 72 | 73 | # Ensure clean zip_name 74 | zip_name = zip_name.replace(' ', '_') 75 | if zip_name.lower().endswith('.zip'): 76 | zip_name = zip_name[:-4] 77 | 78 | zip_path = os.path.join(packages_dir, f"{zip_name}.zip") 79 | 80 | # Remove existing zip if it exists 81 | if os.path.exists(zip_path): 82 | os.remove(zip_path) 83 | 84 | # Create the zip file directly from the temp directory 85 | shutil.make_archive(base_name=os.path.join(packages_dir, zip_name), 86 | format='zip', 87 | root_dir=temp_dir) 88 | 89 | logger.debug(f"Successfully created zip package at: {zip_path}") 90 | return zip_path 91 | except Exception as e: 92 | logger.error(f"Zip creation failed: {str(e)}") 93 | logger.error("Temporary directory: %s", temp_dir) # Log temp directory for debugging 94 | logger.error("Zip name: %s", zip_name) # Log zip name for debugging 95 | raise Exception(f"Package creation error: {str(e)}") -------------------------------------------------------------------------------- /server/backend/services/scripts/data_package_config/modules/certificates.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | from backend.config.logging_config import configure_logging 4 | from backend.services.helpers.run_command import RunCommand 5 | from backend.services.helpers.directories import DirectoryHelper 6 | 7 | logger = configure_logging(__name__) 8 | 9 | class CertificateManager: 10 | def __init__(self, directory_helper: Optional[DirectoryHelper] = None, emit_event=None): 11 | self.directory_helper = directory_helper if directory_helper else DirectoryHelper() 12 | self.run_command = RunCommand() 13 | 14 | async def copy_certificates_from_container(self, temp_dir, ca_certs, client_certs): 15 | """Copies certificates from the container to the temporary directory""" 16 | try: 17 | container_name = f"takserver-{self.directory_helper.get_takserver_version()}" 18 | # Create cert directory in temp directory 19 | cert_dir = os.path.join(temp_dir, 'cert') 20 | os.makedirs(cert_dir, exist_ok=True) 21 | 22 | # Only copy certificates if valid names are provided 23 | if ca_certs: 24 | for ca_cert in ca_certs: 25 | ca_cert_src = f"/opt/tak/certs/files/{ca_cert}" 26 | ca_cert_dest = os.path.join(cert_dir, ca_cert) 27 | copy_ca_cert_command = [ 28 | 'docker', 'cp', f"{container_name}:{ca_cert_src}", ca_cert_dest 29 | ] 30 | result = await self.run_command.run_command_async( 31 | command=copy_ca_cert_command, 32 | event_type="data-package", 33 | ) 34 | if not result.success: 35 | logger.error(f"Failed to copy CA certificate {ca_cert}: {result.stderr}") 36 | raise Exception(f"Failed to copy CA certificate {ca_cert}: {result.stderr}") 37 | 38 | if client_certs: 39 | for client_cert in client_certs: 40 | client_cert_src = f"/opt/tak/certs/files/{client_cert}" 41 | client_cert_dest = os.path.join(cert_dir, client_cert) 42 | copy_client_cert_command = [ 43 | 'docker', 'cp', f"{container_name}:{client_cert_src}", client_cert_dest 44 | ] 45 | result = await self.run_command.run_command_async( 46 | command=copy_client_cert_command, 47 | event_type="data-package", 48 | ) 49 | if not result.success: 50 | logger.error(f"Failed to copy client certificate {client_cert}: {result.stderr}") 51 | raise Exception(f"Failed to copy client certificate {client_cert}: {result.stderr}") 52 | 53 | logger.debug(f"Copied all certificates to {cert_dir}") 54 | except Exception as e: 55 | logger.error(f"Certificate copy failed: {str(e)}") 56 | logger.error("Temporary directory: %s", temp_dir) # Log temp directory for debugging 57 | logger.error("CA certificates: %s", ca_certs) # Log CA certificates for debugging 58 | logger.error("Client certificates: %s", client_certs) # Log client certificates for debugging 59 | raise Exception(f"Certificate transfer error: {str(e)}") 60 | 61 | async def list_cert_files(self) -> list: 62 | """Lists certificate files in the /opt/tak/certs/files directory""" 63 | try: 64 | logger.debug("Listing certificate files in container") 65 | container_name = f"takserver-{self.directory_helper.get_takserver_version()}" 66 | 67 | # Execute command in container 68 | command = ["docker", "exec", container_name, "ls", "/opt/tak/certs/files"] 69 | result = await self.run_command.run_command_async( 70 | command=command, 71 | event_type="data-package", 72 | ) 73 | 74 | if not result.success: 75 | logger.error(f"Certificate listing failed: {result.stderr}") 76 | raise Exception(f"Certificate listing failed: {result.stderr}") 77 | 78 | cert_files = result.stdout.splitlines() 79 | logger.debug(f"Found certificate files: {cert_files}") 80 | return [f for f in cert_files if f.endswith(('.p12', '.pem'))] 81 | 82 | except Exception as e: 83 | logger.error(f"Certificate listing error: {str(e)}") 84 | logger.error("Container name: %s", container_name) # Log container name for debugging 85 | raise Exception(f"Failed to list certificates: {str(e)}") -------------------------------------------------------------------------------- /server/backend/services/scripts/data_package_config/data_package.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | from typing import Dict, Any 4 | from backend.config.logging_config import configure_logging 5 | from backend.services.helpers.directories import DirectoryHelper 6 | from backend.services.helpers.run_command import RunCommand 7 | from fastapi import UploadFile 8 | 9 | # Import modules 10 | from .modules.preferences import PreferencesManager 11 | from .modules.certificates import CertificateManager 12 | from .modules.custom_files import CustomFilesManager 13 | from .modules.package_creator import PackageCreator 14 | 15 | logger = configure_logging(__name__) 16 | 17 | class DataPackage: 18 | def __init__(self): 19 | self.run_command = RunCommand() 20 | self.directory_helper = DirectoryHelper() 21 | self.working_dir = self.directory_helper.get_default_working_directory() 22 | 23 | # Initialize module managers 24 | self.preferences_manager = PreferencesManager() 25 | self.certificate_manager = CertificateManager(self.directory_helper) 26 | self.custom_files_manager = CustomFilesManager(self.directory_helper) 27 | self.package_creator = PackageCreator(self.directory_helper) 28 | 29 | async def main(self, preferences_data) -> Dict[str, Any]: 30 | """Main method for data package creation""" 31 | try: 32 | with tempfile.TemporaryDirectory() as temp_dir: 33 | zip_name = preferences_data.get('#zip_file_name', 'data_package') 34 | custom_files = preferences_data.get('customFiles', []) 35 | logger.debug(f"[main] Processing custom files: {custom_files}") 36 | 37 | # Check if enrollment is enabled 38 | enrollment_enabled = preferences_data.get('enrollment', False) 39 | 40 | # Extract certificate names 41 | stream_count = int(preferences_data.get('count', 1)) 42 | ca_certs, client_certs = self.preferences_manager.extract_certificates(preferences_data, stream_count) 43 | 44 | # Generate configuration (without custom files in preferences) 45 | clean_preferences = self.preferences_manager.clean_preferences_data(preferences_data) 46 | if 'customFiles' in clean_preferences: 47 | logger.debug("[main] Removing customFiles from preferences") 48 | del clean_preferences['customFiles'] # Remove custom files from preferences 49 | self.preferences_manager.generate_config_pref(clean_preferences, temp_dir) 50 | 51 | # Create manifest with custom files 52 | manifest_path = self.package_creator.create_manifest_file(temp_dir, zip_name, ca_certs, client_certs, custom_files) 53 | logger.debug(f"[main] Created manifest at: {manifest_path}") 54 | 55 | # Copy certificates if needed 56 | if ca_certs or client_certs: 57 | await self.certificate_manager.copy_certificates_from_container(temp_dir, ca_certs, client_certs) 58 | 59 | # Copy custom files to root of temp directory 60 | await self.custom_files_manager.copy_custom_files(temp_dir, custom_files) 61 | 62 | # Log directory contents before zipping 63 | logger.debug(f"[main] Temp directory contents: {os.listdir(temp_dir)}") 64 | 65 | zip_path = await self.package_creator.create_zip_file(temp_dir, zip_name) 66 | return {'status': 'success', 'path': zip_path} 67 | 68 | except Exception as e: 69 | logger.error(f"[main] Data package creation failed: {str(e)}") 70 | logger.error("Preferences data: %s", preferences_data) # Log preferences data for debugging 71 | return {'error': str(e)} 72 | 73 | # Maintain the public API by exposing methods from managers 74 | 75 | # Certificate methods 76 | async def list_cert_files(self) -> list: 77 | return await self.certificate_manager.list_cert_files() 78 | 79 | # Custom files methods 80 | def get_custom_files_directory(self): 81 | return self.custom_files_manager.get_custom_files_directory() 82 | 83 | async def list_custom_files(self) -> list: 84 | return await self.custom_files_manager.list_custom_files() 85 | 86 | async def list_custom_files_with_metadata(self) -> list: 87 | return await self.custom_files_manager.list_custom_files_with_metadata() 88 | 89 | async def save_custom_file(self, file: UploadFile): 90 | return await self.custom_files_manager.save_custom_file(file) 91 | 92 | async def delete_custom_file(self, filename: str): 93 | return await self.custom_files_manager.delete_custom_file(filename) -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SheetPrimitive from "@radix-ui/react-dialog" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | import { ChevronLeft } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const Sheet = SheetPrimitive.Root 11 | 12 | const SheetTrigger = SheetPrimitive.Trigger 13 | 14 | const SheetClose = SheetPrimitive.Close 15 | 16 | const SheetPortal = SheetPrimitive.Portal 17 | 18 | const SheetOverlay = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, ...props }, ref) => ( 22 | 30 | )) 31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 32 | 33 | const sheetVariants = cva( 34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", 35 | { 36 | variants: { 37 | side: { 38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 39 | bottom: 40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 41 | left: "inset-y-0 left-0 h-full w-full border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left xl:max-w-sm", 42 | right: 43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 44 | }, 45 | }, 46 | defaultVariants: { 47 | side: "right", 48 | }, 49 | } 50 | ) 51 | 52 | interface SheetContentProps 53 | extends React.ComponentPropsWithoutRef, 54 | VariantProps {} 55 | 56 | const SheetContent = React.forwardRef< 57 | React.ElementRef, 58 | SheetContentProps 59 | >(({ side = "right", className, children, ...props }, ref) => ( 60 | 61 | 62 | 67 | {children} 68 | 69 | 70 | Close 71 | 72 | 73 | 74 | )) 75 | SheetContent.displayName = SheetPrimitive.Content.displayName 76 | 77 | const SheetHeader = ({ 78 | className, 79 | ...props 80 | }: React.HTMLAttributes) => ( 81 |
88 | ) 89 | SheetHeader.displayName = "SheetHeader" 90 | 91 | const SheetFooter = ({ 92 | className, 93 | ...props 94 | }: React.HTMLAttributes) => ( 95 |
102 | ) 103 | SheetFooter.displayName = "SheetFooter" 104 | 105 | const SheetTitle = React.forwardRef< 106 | React.ElementRef, 107 | React.ComponentPropsWithoutRef 108 | >(({ className, ...props }, ref) => ( 109 | 114 | )) 115 | SheetTitle.displayName = SheetPrimitive.Title.displayName 116 | 117 | const SheetDescription = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, ...props }, ref) => ( 121 | 126 | )) 127 | SheetDescription.displayName = SheetPrimitive.Description.displayName 128 | 129 | export { 130 | Sheet, 131 | SheetPortal, 132 | SheetOverlay, 133 | SheetTrigger, 134 | SheetClose, 135 | SheetContent, 136 | SheetHeader, 137 | SheetFooter, 138 | SheetTitle, 139 | SheetDescription, 140 | } 141 | -------------------------------------------------------------------------------- /server/backend/routes/takserver_api_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException 2 | from backend.services.scripts.takserver.connected_clients import ConnectedClients 3 | from backend.config.logging_config import configure_logging 4 | from sse_starlette.sse import EventSourceResponse 5 | import asyncio 6 | import json 7 | 8 | logger = configure_logging(__name__) 9 | 10 | takserver_api = APIRouter() 11 | 12 | # Create a queue for connected clients events 13 | connected_clients_queue = asyncio.Queue() 14 | 15 | # Track the monitoring task 16 | connected_clients_monitor = None 17 | connected_clients_instance = None 18 | # Track active connections 19 | active_connections = 0 20 | 21 | @takserver_api.get('/connected-clients') 22 | async def get_connected_clients(): 23 | """Get the list of connected clients.""" 24 | client = ConnectedClients() 25 | result = await client.execute_curl_command() 26 | 27 | if result is None: 28 | raise HTTPException(status_code=500, detail="Failed to retrieve connected clients.") 29 | 30 | return json.loads(result) 31 | 32 | @takserver_api.get('/connected-clients-stream') 33 | async def connected_clients_stream(): 34 | """Server-Sent Events (SSE) stream for connected clients. 35 | 36 | Provides real-time updates when clients connect or disconnect from the TAK Server. 37 | Updates are only sent when there are changes to avoid unnecessary traffic. 38 | """ 39 | global connected_clients_monitor, connected_clients_instance, active_connections 40 | 41 | # Increment active connections counter 42 | active_connections += 1 43 | logger.debug(f"New client connected to SSE stream. Active connections: {active_connections}") 44 | 45 | # Start the monitoring task if it's not already running 46 | if connected_clients_monitor is None or connected_clients_monitor.done(): 47 | logger.info("Starting new connected clients monitoring task") 48 | connected_clients_instance = ConnectedClients() 49 | connected_clients_monitor = asyncio.create_task( 50 | connected_clients_instance.start_monitoring(connected_clients_queue) 51 | ) 52 | 53 | async def event_generator(): 54 | try: 55 | while True: 56 | event = await connected_clients_queue.get() 57 | if event["event"] == "ping": 58 | # Send an empty ping event to keep the connection alive 59 | yield {"event": "ping", "data": ""} 60 | else: 61 | # Send the actual event data 62 | yield { 63 | "event": event["event"], 64 | "data": event["data"] 65 | } 66 | except asyncio.CancelledError: 67 | logger.info("Client disconnected from connected clients stream") 68 | raise 69 | finally: 70 | # Decrement active connections counter when client disconnects 71 | global active_connections 72 | active_connections -= 1 73 | logger.debug(f"Client disconnected from SSE stream. Active connections: {active_connections}") 74 | 75 | # If no more active connections, stop the monitoring task 76 | if active_connections <= 0: 77 | await stop_monitoring() 78 | 79 | return EventSourceResponse(event_generator()) 80 | 81 | @takserver_api.post('/connected-clients-stream/stop') 82 | async def stop_connected_clients_stream(): 83 | """Stop the connected clients monitoring task.""" 84 | await stop_monitoring() 85 | return {"status": "success", "message": "Monitoring stopped"} 86 | 87 | async def stop_monitoring(): 88 | """Stop the monitoring task if there are no active connections.""" 89 | global connected_clients_monitor, connected_clients_instance, active_connections 90 | 91 | # Reset active connections counter to ensure we don't have negative values 92 | if active_connections <= 0: 93 | active_connections = 0 94 | 95 | if connected_clients_instance: 96 | logger.info("Stopping connected clients monitoring task") 97 | connected_clients_instance.stop_monitoring() 98 | connected_clients_instance = None 99 | 100 | if connected_clients_monitor and not connected_clients_monitor.done(): 101 | logger.info("Cancelling connected clients monitoring task") 102 | connected_clients_monitor.cancel() 103 | try: 104 | await connected_clients_monitor 105 | except asyncio.CancelledError: 106 | pass 107 | connected_clients_monitor = None 108 | 109 | logger.info("Connected clients monitoring stopped") 110 | 111 | # Cleanup on shutdown 112 | @takserver_api.on_event("shutdown") 113 | async def shutdown_event(): 114 | await stop_monitoring() 115 | -------------------------------------------------------------------------------- /client/src/components/transfer/TransferStatus/DeviceProgress.jsx: -------------------------------------------------------------------------------- 1 | import { CloseIcon } from '../../shared/ui/icons/CloseIcon'; 2 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../shared/ui/shadcn/tooltip/tooltip'; 3 | 4 | export const DeviceProgress = ({ deviceId, progress, onRemoveFailed, isTransferRunning, isDeviceConnected }) => { 5 | // Different visibility rules: 6 | // 1. Always show if transfer is running 7 | // 2. For completed status: only show if device is still connected 8 | // 3. For failed status: show until manually closed (regardless of connection) 9 | if (!isTransferRunning && 10 | !(progress.status === 'completed' && isDeviceConnected) && 11 | progress.status !== 'failed') { 12 | return null; 13 | } 14 | 15 | // Get progress bar color based on status 16 | const getProgressBarColor = (status) => { 17 | switch (status) { 18 | case 'completed': 19 | return 'bg-green-500'; 20 | case 'failed': 21 | return 'bg-red-500'; 22 | case 'transferring': 23 | return 'bg-selectedColor'; 24 | case 'preparing': 25 | return 'bg-yellow-500'; 26 | default: 27 | return 'bg-primary'; 28 | } 29 | }; 30 | 31 | // Get progress text based on status and progress info 32 | const getProgressText = () => { 33 | if (!progress) return ''; 34 | 35 | switch (progress.status) { 36 | case 'failed': 37 | return `${progress.progress || 0}% (Failed)`; 38 | case 'completed': 39 | return '100% (Complete)'; 40 | case 'preparing': 41 | return 'Preparing...'; 42 | case 'transferring': 43 | return `${progress.progress}% (File ${progress.fileNumber}/${progress.totalFiles}: ${progress.fileProgress}%)`; 44 | default: 45 | return `${progress.progress || 0}%`; 46 | } 47 | }; 48 | 49 | // Get status text with proper formatting 50 | const getStatusText = () => { 51 | if (!progress) return ''; 52 | 53 | switch (progress.status) { 54 | case 'failed': 55 | return 'Transfer Failed'; 56 | case 'completed': 57 | return 'Transfer Complete'; 58 | case 'preparing': 59 | return 'Preparing Transfer'; 60 | case 'transferring': 61 | return 'Transferring Files'; 62 | default: 63 | return progress.status || 'Unknown'; 64 | } 65 | }; 66 | 67 | return ( 68 |
69 |
70 | Device: {deviceId} 71 |
72 | 73 | {getProgressText()} 74 | 75 | {progress.status === 'failed' && ( 76 | 77 | 78 | 79 | 89 | 90 | 91 |

Remove failed transfer from list

92 |
93 |
94 |
95 | )} 96 |
97 |
98 |
99 |
109 |
110 |
111 | {progress.currentFile && ( 112 | 113 | Current File: {progress.currentFile} 114 | 115 | )} 116 | 121 | Status: {getStatusText()} 122 | 123 |
124 |
125 | ); 126 | }; -------------------------------------------------------------------------------- /client/src/components/shared/ui/shadcn/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/shared/ui/shadcn/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |