mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2026-02-17 03:49:42 -06:00
Mobile Ui mainly
Fixes on Mobile responsiveness.
This commit is contained in:
58
.gitattributes
vendored
Normal file
58
.gitattributes
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
# Set default behavior to automatically normalize line endings
|
||||
* text=auto
|
||||
|
||||
# Explicitly declare text files you want to always be normalized and converted
|
||||
# to native line endings on checkout
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.jsonc text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.prisma text eol=lf
|
||||
*.sql text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
*.css text eol=lf
|
||||
*.html text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.svg text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.env text eol=lf
|
||||
*.env.* text eol=lf
|
||||
*.config.js text eol=lf
|
||||
*.config.ts text eol=lf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.tar.gz binary
|
||||
*.gz binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
*.so binary
|
||||
*.dylib binary
|
||||
|
||||
# Database files
|
||||
*.db binary
|
||||
*.sqlite binary
|
||||
*.sqlite3 binary
|
||||
|
||||
# Lock files should use LF
|
||||
package-lock.json text eol=lf
|
||||
yarn.lock text eol=lf
|
||||
pnpm-lock.yaml text eol=lf
|
||||
|
||||
@@ -34,12 +34,12 @@
|
||||
"speakeasy": "^2.0.0",
|
||||
"uuid": "^11.0.3",
|
||||
"winston": "^3.17.0",
|
||||
"prisma": "^6.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"nodemon": "^3.1.9",
|
||||
"prisma": "^6.1.0"
|
||||
"nodemon": "^3.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -215,7 +215,7 @@ const GlobalSearch = () => {
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="block w-full rounded-lg border border-secondary-200 bg-white py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400"
|
||||
className="block w-full rounded-lg border border-secondary-200 bg-white py-2.5 sm:py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400 min-h-[44px]"
|
||||
placeholder="Search hosts, packages, repos, users..."
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
@@ -228,7 +228,8 @@ const GlobalSearch = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600 min-w-[44px] min-h-[44px] justify-center"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -237,9 +238,9 @@ const GlobalSearch = () => {
|
||||
|
||||
{/* Dropdown Results */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-2 w-full rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800">
|
||||
<div className="absolute z-50 mt-2 w-full sm:w-[calc(100vw-2rem)] sm:max-w-md rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800 left-0 sm:left-auto right-0 sm:right-auto">
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-2 text-center text-sm text-secondary-500">
|
||||
<div className="px-4 py-2 text-center text-sm text-secondary-500 dark:text-white/70">
|
||||
Searching...
|
||||
</div>
|
||||
) : hasResults ? (
|
||||
@@ -247,7 +248,7 @@ const GlobalSearch = () => {
|
||||
{/* Hosts */}
|
||||
{results.hosts?.length > 0 && (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-white/80">
|
||||
Hosts
|
||||
</div>
|
||||
{results.hosts.map((host, _idx) => {
|
||||
@@ -260,7 +261,7 @@ const GlobalSearch = () => {
|
||||
type="button"
|
||||
key={host.id}
|
||||
onClick={() => handleResultClick(host)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||
className={`flex w-full items-center gap-2 px-3 py-3 sm:py-1.5 text-left transition-colors min-h-[44px] ${
|
||||
globalIdx === selectedIndex
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
@@ -271,12 +272,14 @@ const GlobalSearch = () => {
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{display.primary}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400">•</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
<span className="text-xs text-secondary-400 dark:text-white/50">
|
||||
•
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-white/70 truncate">
|
||||
{display.secondary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400 dark:text-white/60">
|
||||
{host.os_type}
|
||||
</div>
|
||||
</button>
|
||||
@@ -288,7 +291,7 @@ const GlobalSearch = () => {
|
||||
{/* Packages */}
|
||||
{results.packages?.length > 0 && (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-white/80">
|
||||
Packages
|
||||
</div>
|
||||
{results.packages.map((pkg, _idx) => {
|
||||
@@ -301,7 +304,7 @@ const GlobalSearch = () => {
|
||||
type="button"
|
||||
key={pkg.id}
|
||||
onClick={() => handleResultClick(pkg)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||
className={`flex w-full items-center gap-2 px-3 py-3 sm:py-1.5 text-left transition-colors min-h-[44px] ${
|
||||
globalIdx === selectedIndex
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
@@ -317,13 +320,13 @@ const GlobalSearch = () => {
|
||||
<span className="text-xs text-secondary-400">
|
||||
•
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
<span className="text-xs text-secondary-500 dark:text-white/70 truncate">
|
||||
{display.secondary}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400 dark:text-white/60">
|
||||
{pkg.host_count} hosts
|
||||
</div>
|
||||
</button>
|
||||
@@ -335,7 +338,7 @@ const GlobalSearch = () => {
|
||||
{/* Repositories */}
|
||||
{results.repositories?.length > 0 && (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-white/80">
|
||||
Repositories
|
||||
</div>
|
||||
{results.repositories.map((repo, _idx) => {
|
||||
@@ -348,7 +351,7 @@ const GlobalSearch = () => {
|
||||
type="button"
|
||||
key={repo.id}
|
||||
onClick={() => handleResultClick(repo)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||
className={`flex w-full items-center gap-2 px-3 py-3 sm:py-1.5 text-left transition-colors min-h-[44px] ${
|
||||
globalIdx === selectedIndex
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
@@ -359,12 +362,14 @@ const GlobalSearch = () => {
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{display.primary}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400">•</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
<span className="text-xs text-secondary-400 dark:text-white/50">
|
||||
•
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-white/70 truncate">
|
||||
{display.secondary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400 dark:text-white/60">
|
||||
{repo.host_count} hosts
|
||||
</div>
|
||||
</button>
|
||||
@@ -376,7 +381,7 @@ const GlobalSearch = () => {
|
||||
{/* Users */}
|
||||
{results.users?.length > 0 && (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-white/80">
|
||||
Users
|
||||
</div>
|
||||
{results.users.map((user, _idx) => {
|
||||
@@ -389,7 +394,7 @@ const GlobalSearch = () => {
|
||||
type="button"
|
||||
key={user.id}
|
||||
onClick={() => handleResultClick(user)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||
className={`flex w-full items-center gap-2 px-3 py-3 sm:py-1.5 text-left transition-colors min-h-[44px] ${
|
||||
globalIdx === selectedIndex
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
@@ -400,12 +405,14 @@ const GlobalSearch = () => {
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{display.primary}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400">•</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
<span className="text-xs text-secondary-400 dark:text-white/50">
|
||||
•
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-white/70 truncate">
|
||||
{display.secondary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400 dark:text-white/60">
|
||||
{user.role}
|
||||
</div>
|
||||
</button>
|
||||
@@ -415,7 +422,7 @@ const GlobalSearch = () => {
|
||||
)}
|
||||
</div>
|
||||
) : query.trim() ? (
|
||||
<div className="px-4 py-2 text-center text-sm text-secondary-500">
|
||||
<div className="px-4 py-2 text-center text-sm text-secondary-500 dark:text-white/70">
|
||||
No results found for "{query}"
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Code,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
const SettingsLayout = ({ children }) => {
|
||||
@@ -155,29 +156,73 @@ const SettingsLayout = ({ children }) => {
|
||||
};
|
||||
|
||||
const secondaryNavigation = buildSecondaryNavigation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isActive = (path) => location.pathname === path;
|
||||
|
||||
const _getPageTitle = () => {
|
||||
const path = location.pathname;
|
||||
// Flatten all navigation items for dropdown
|
||||
const getAllNavItems = () => {
|
||||
const items = [];
|
||||
secondaryNavigation.forEach((section) => {
|
||||
section.items.forEach((item) => {
|
||||
if (!item.comingSoon) {
|
||||
items.push({
|
||||
...item,
|
||||
section: section.section,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return items;
|
||||
};
|
||||
|
||||
if (path.startsWith("/settings/users")) return "Users";
|
||||
if (path.startsWith("/settings/host-groups")) return "Host Groups";
|
||||
if (path.startsWith("/settings/notifications")) return "Notifications";
|
||||
if (path.startsWith("/settings/agent-config")) return "Agent Config";
|
||||
if (path.startsWith("/settings/server-config")) return "Server Config";
|
||||
const allNavItems = getAllNavItems();
|
||||
|
||||
return "Settings";
|
||||
const _getCurrentPageTitle = () => {
|
||||
const currentItem = allNavItems.find((item) => isActive(item.href));
|
||||
return currentItem ? currentItem.name : "Settings";
|
||||
};
|
||||
|
||||
const handleDropdownChange = (e) => {
|
||||
const selectedHref = e.target.value;
|
||||
if (selectedHref) {
|
||||
navigate(selectedHref);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-transparent">
|
||||
{/* Within-page secondary navigation and content */}
|
||||
<div className="px-2 sm:px-4 lg:px-6">
|
||||
{/* Mobile Dropdown */}
|
||||
<div className="md:hidden mb-4">
|
||||
<label
|
||||
htmlFor="settings-select"
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||
>
|
||||
Settings Section
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="settings-select"
|
||||
value={location.pathname}
|
||||
onChange={handleDropdownChange}
|
||||
className="block w-full pl-3 pr-10 py-2 text-base border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 appearance-none"
|
||||
>
|
||||
{allNavItems.map((item) => (
|
||||
<option key={item.href} value={item.href}>
|
||||
{item.section} - {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-secondary-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{/* Left secondary nav (within page) */}
|
||||
{/* Left secondary nav (within page) - Hidden on mobile */}
|
||||
<aside
|
||||
className={`${sidebarCollapsed ? "w-14" : "w-56"} transition-all duration-300 flex-shrink-0`}
|
||||
className={`hidden md:block ${sidebarCollapsed ? "w-14" : "w-56"} transition-all duration-300 flex-shrink-0`}
|
||||
>
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg">
|
||||
{/* Collapse button */}
|
||||
|
||||
@@ -247,18 +247,18 @@ const AgentManagementTab = () => {
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-secondary-900 dark:text-white mb-2">
|
||||
<div className="mb-4 md:mb-6">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-secondary-900 dark:text-white mb-2">
|
||||
Agent Version Management
|
||||
</h2>
|
||||
<p className="text-secondary-600 dark:text-secondary-400">
|
||||
<p className="text-sm md:text-base text-secondary-600 dark:text-secondary-400">
|
||||
Monitor and manage agent versions across your infrastructure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Banner */}
|
||||
<div
|
||||
className={`rounded-xl shadow-sm p-6 border-2 ${
|
||||
className={`rounded-xl shadow-sm p-4 md:p-6 border-2 ${
|
||||
versionStatus.status === "up-to-date"
|
||||
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800"
|
||||
: versionStatus.status === "update-available"
|
||||
@@ -268,10 +268,10 @@ const AgentManagementTab = () => {
|
||||
: "bg-white dark:bg-secondary-800 border-secondary-200 dark:border-secondary-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex flex-col sm:flex-row items-start justify-between gap-4">
|
||||
<div className="flex items-start space-x-3 md:space-x-4 flex-1 min-w-0">
|
||||
<div
|
||||
className={`p-3 rounded-lg ${
|
||||
className={`p-2 md:p-3 rounded-lg flex-shrink-0 ${
|
||||
versionStatus.status === "up-to-date"
|
||||
? "bg-green-100 dark:bg-green-800"
|
||||
: versionStatus.status === "update-available"
|
||||
@@ -282,14 +282,16 @@ const AgentManagementTab = () => {
|
||||
}`}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon className={`h-6 w-6 ${versionStatus.color}`} />
|
||||
<StatusIcon
|
||||
className={`h-5 w-5 md:h-6 md:w-6 ${versionStatus.color}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base md:text-lg font-semibold text-secondary-900 dark:text-white mb-1">
|
||||
{versionStatus.message}
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<p className="text-xs md:text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{versionStatus.status === "up-to-date" &&
|
||||
"All agent binaries are current"}
|
||||
{versionStatus.status === "update-available" &&
|
||||
@@ -312,7 +314,7 @@ const AgentManagementTab = () => {
|
||||
type="button"
|
||||
onClick={() => checkUpdatesMutation.mutate()}
|
||||
disabled={checkUpdatesMutation.isPending}
|
||||
className="flex items-center px-4 py-2 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 border border-secondary-300 dark:border-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow"
|
||||
className="flex items-center px-3 md:px-4 py-2 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 border border-secondary-300 dark:border-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow w-full sm:w-auto justify-center sm:justify-start flex-shrink-0"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${checkUpdatesMutation.isPending ? "animate-spin" : ""}`}
|
||||
@@ -325,15 +327,15 @@ const AgentManagementTab = () => {
|
||||
</div>
|
||||
|
||||
{/* Version Information Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
{/* Current Version Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
|
||||
<h4 className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-4 md:p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
|
||||
<h4 className="text-xs md:text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
Current Version
|
||||
</h4>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
<p className="text-xl md:text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{versionInfo?.currentVersion || (
|
||||
<span className="text-lg text-secondary-400 dark:text-secondary-500">
|
||||
<span className="text-base md:text-lg text-secondary-400 dark:text-secondary-500">
|
||||
Not detected
|
||||
</span>
|
||||
)}
|
||||
@@ -341,13 +343,13 @@ const AgentManagementTab = () => {
|
||||
</div>
|
||||
|
||||
{/* Latest Version Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
|
||||
<h4 className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-4 md:p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
|
||||
<h4 className="text-xs md:text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
Latest Available
|
||||
</h4>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
<p className="text-xl md:text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{versionInfo?.latestVersion || (
|
||||
<span className="text-lg text-secondary-400 dark:text-secondary-500">
|
||||
<span className="text-base md:text-lg text-secondary-400 dark:text-secondary-500">
|
||||
Unknown
|
||||
</span>
|
||||
)}
|
||||
@@ -355,11 +357,11 @@ const AgentManagementTab = () => {
|
||||
</div>
|
||||
|
||||
{/* Last Checked Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
|
||||
<h4 className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-4 md:p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200 col-span-2 lg:col-span-1">
|
||||
<h4 className="text-xs md:text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
Last Checked
|
||||
</h4>
|
||||
<p className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
<p className="text-base md:text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
{versionInfo?.lastChecked
|
||||
? new Date(versionInfo.lastChecked).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
@@ -373,29 +375,29 @@ const AgentManagementTab = () => {
|
||||
</div>
|
||||
|
||||
{/* Download Updates Section */}
|
||||
<div className="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-secondary-800 dark:to-secondary-800 rounded-xl shadow-sm p-8 border border-primary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-secondary-800 dark:to-secondary-800 rounded-xl shadow-sm p-4 md:p-8 border border-primary-200 dark:border-secondary-600">
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold text-secondary-900 dark:text-white mb-3">
|
||||
<h3 className="text-lg md:text-xl font-bold text-secondary-900 dark:text-white mb-3">
|
||||
{!versionInfo?.currentVersion
|
||||
? "Get Started with Agent Binaries"
|
||||
: versionStatus.status === "update-available"
|
||||
? "New Agent Version Available"
|
||||
: "Agent Binaries"}
|
||||
</h3>
|
||||
<p className="text-secondary-700 dark:text-secondary-300 mb-4">
|
||||
<p className="text-sm md:text-base text-secondary-700 dark:text-secondary-300 mb-4">
|
||||
{!versionInfo?.currentVersion
|
||||
? "No agent binaries detected. Download from GitHub to begin managing your agents."
|
||||
: versionStatus.status === "update-available"
|
||||
? `A new agent version (${versionInfo.latestVersion}) is available. Download the latest binaries from GitHub.`
|
||||
: "Download or redownload agent binaries from GitHub."}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadUpdateMutation.mutate()}
|
||||
disabled={downloadUpdateMutation.isPending}
|
||||
className="flex items-center px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-md hover:shadow-lg font-medium"
|
||||
className="flex items-center justify-center px-4 md:px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
{downloadUpdateMutation.isPending ? (
|
||||
<>
|
||||
@@ -417,7 +419,7 @@ const AgentManagementTab = () => {
|
||||
href="https://github.com/PatchMon/PatchMon-agent/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center px-4 py-3 text-secondary-700 dark:text-secondary-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200 font-medium"
|
||||
className="flex items-center justify-center px-4 py-3 text-secondary-700 dark:text-secondary-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200 font-medium border border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View on GitHub
|
||||
@@ -430,17 +432,17 @@ const AgentManagementTab = () => {
|
||||
{/* Supported Architectures */}
|
||||
{versionInfo?.supportedArchitectures &&
|
||||
versionInfo.supportedArchitectures.length > 0 && (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-4 md:p-6 border border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-base md:text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Supported Architectures
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{versionInfo.supportedArchitectures.map((arch) => (
|
||||
<div
|
||||
key={arch}
|
||||
className="flex items-center justify-center px-4 py-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg border border-secondary-200 dark:border-secondary-600"
|
||||
className="flex items-center justify-center px-3 md:px-4 py-2 md:py-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg border border-secondary-200 dark:border-secondary-600"
|
||||
>
|
||||
<code className="text-sm font-mono text-secondary-700 dark:text-secondary-300">
|
||||
<code className="text-xs md:text-sm font-mono text-secondary-700 dark:text-secondary-300">
|
||||
{arch}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ const AgentUpdatesTab = () => {
|
||||
// Fallback clipboard copy function for HTTP and older browsers
|
||||
const copyToClipboard = async (text) => {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
@@ -324,7 +324,7 @@ const AgentUpdatesTab = () => {
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => handleInputChange("updateInterval", m)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium border ${
|
||||
className={`px-2 md:px-3 py-1 md:py-1.5 rounded-md text-xs font-medium border ${
|
||||
formData.updateInterval === m
|
||||
? "bg-primary-600 text-white border-primary-600"
|
||||
: "bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-600"
|
||||
@@ -348,9 +348,8 @@ const AgentUpdatesTab = () => {
|
||||
const raw = parseInt(e.target.value, 10);
|
||||
handleInputChange("updateInterval", normalizeInterval(raw));
|
||||
}}
|
||||
className="w-auto accent-primary-600"
|
||||
className="w-full accent-primary-600"
|
||||
aria-label="Update interval slider"
|
||||
style={{ width: "fit-content", minWidth: "500px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -511,7 +510,7 @@ const AgentUpdatesTab = () => {
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || updateSettingsMutation.isPending}
|
||||
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
|
||||
className={`inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white w-full sm:w-auto ${
|
||||
!isDirty || updateSettingsMutation.isPending
|
||||
? "bg-secondary-400 cursor-not-allowed"
|
||||
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
@@ -562,8 +561,8 @@ const AgentUpdatesTab = () => {
|
||||
<div className="text-xs font-semibold text-red-700 dark:text-red-300 mb-1">
|
||||
Standard Removal (preserves backups):
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1 break-all overflow-x-auto">
|
||||
curl {formData.ignoreSslSelfSigned ? "-sk" : "-s"}{" "}
|
||||
{window.location.origin}/api/v1/hosts/remove | sudo sh
|
||||
</div>
|
||||
@@ -586,7 +585,7 @@ const AgentUpdatesTab = () => {
|
||||
showToast("Failed to copy to clipboard", "error");
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
|
||||
className="px-3 py-2 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors flex-shrink-0 whitespace-nowrap"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
@@ -600,8 +599,8 @@ const AgentUpdatesTab = () => {
|
||||
<div className="text-xs font-semibold text-red-700 dark:text-red-300 mb-1">
|
||||
Complete Removal (includes backups):
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1 break-all overflow-x-auto">
|
||||
curl {formData.ignoreSslSelfSigned ? "-sk" : "-s"}{" "}
|
||||
{window.location.origin}/api/v1/hosts/remove | sudo
|
||||
REMOVE_BACKUPS=1 sh
|
||||
@@ -625,7 +624,7 @@ const AgentUpdatesTab = () => {
|
||||
showToast("Failed to copy to clipboard", "error");
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
|
||||
className="px-3 py-2 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors flex-shrink-0 whitespace-nowrap"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
|
||||
@@ -132,177 +132,316 @@ const UsersTab = () => {
|
||||
<div className="space-y-6">
|
||||
{/* Users Table */}
|
||||
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Last Login
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{users && Array.isArray(users) && users.length > 0 ? (
|
||||
users.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<div className="h-10 w-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{user.username}
|
||||
</div>
|
||||
{user.id === currentUser?.id && (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{users && Array.isArray(users) && users.length > 0 ? (
|
||||
<>
|
||||
{/* Mobile Card Layout */}
|
||||
<div className="md:hidden space-y-3 p-4">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="card p-4 space-y-3">
|
||||
{/* User Name and Avatar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<div className="h-10 w-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center text-sm text-secondary-500 dark:text-secondary-300">
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
{user.email}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-base font-semibold text-secondary-900 dark:text-white truncate">
|
||||
{user.username}
|
||||
</div>
|
||||
{user.id === currentUser?.id && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 flex-shrink-0">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium ${
|
||||
user.role === "admin"
|
||||
? "bg-primary-100 text-primary-800"
|
||||
: user.role === "host_manager"
|
||||
? "bg-green-100 text-green-800"
|
||||
: user.role === "readonly"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-secondary-100 text-secondary-800"
|
||||
}`}
|
||||
>
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
{user.role.charAt(0).toUpperCase() +
|
||||
user.role.slice(1).replace("_", " ")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="h-4 w-4 text-secondary-400 flex-shrink-0" />
|
||||
<span className="text-secondary-900 dark:text-white truncate">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Role and Status */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium ${
|
||||
user.role === "admin"
|
||||
? "bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200"
|
||||
: user.role === "host_manager"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: user.role === "readonly"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
||||
: "bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200"
|
||||
}`}
|
||||
>
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
{user.role.charAt(0).toUpperCase() +
|
||||
user.role.slice(1).replace("_", " ")}
|
||||
</span>
|
||||
{user.is_active ? (
|
||||
<div className="flex items-center text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
<span className="text-xs font-medium">Active</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-red-600 dark:text-red-400">
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
<span className="text-xs font-medium">Inactive</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Created and Last Login */}
|
||||
<div className="space-y-2 pt-2 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="h-4 w-4 text-secondary-400 flex-shrink-0" />
|
||||
<span className="text-secondary-500 dark:text-secondary-400">
|
||||
Created:
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{user.is_active ? (
|
||||
<div className="flex items-center text-green-600">
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
<span className="text-sm">Active</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-red-600">
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
<span className="text-sm">Inactive</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center text-sm text-secondary-500 dark:text-secondary-300">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
<span className="text-secondary-900 dark:text-white">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{user.last_login ? (
|
||||
new Date(user.last_login).toLocaleDateString()
|
||||
) : (
|
||||
<span className="text-secondary-400">Never</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEditUser(user)}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
title="Edit user"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResetPassword(user)}
|
||||
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed"
|
||||
title={
|
||||
!user.is_active
|
||||
? "Cannot reset password for inactive user"
|
||||
: "Reset password"
|
||||
}
|
||||
disabled={!user.is_active}
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleDeleteUser(user.id, user.username)
|
||||
}
|
||||
className="text-danger-400 hover:text-danger-600 dark:text-danger-500 dark:hover:text-danger-400 disabled:text-gray-300 disabled:cursor-not-allowed"
|
||||
title={
|
||||
user.id === currentUser?.id
|
||||
? "Cannot delete your own account"
|
||||
: user.role === "admin" &&
|
||||
users.filter((u) => u.role === "admin")
|
||||
.length === 1
|
||||
? "Cannot delete the last admin user"
|
||||
: "Delete user"
|
||||
}
|
||||
disabled={
|
||||
user.id === currentUser?.id ||
|
||||
(user.role === "admin" &&
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-secondary-500 dark:text-secondary-400">
|
||||
Last Login:
|
||||
</span>
|
||||
<span className="text-secondary-900 dark:text-white">
|
||||
{user.last_login
|
||||
? new Date(user.last_login).toLocaleDateString()
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEditUser(user)}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 inline-flex items-center gap-1 text-sm"
|
||||
title="Edit user"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResetPassword(user)}
|
||||
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed inline-flex items-center gap-1 text-sm"
|
||||
title={
|
||||
!user.is_active
|
||||
? "Cannot reset password for inactive user"
|
||||
: "Reset password"
|
||||
}
|
||||
disabled={!user.is_active}
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteUser(user.id, user.username)}
|
||||
className="text-danger-400 hover:text-danger-600 dark:text-danger-500 dark:hover:text-danger-400 disabled:text-gray-300 disabled:cursor-not-allowed inline-flex items-center gap-1 text-sm"
|
||||
title={
|
||||
user.id === currentUser?.id
|
||||
? "Cannot delete your own account"
|
||||
: user.role === "admin" &&
|
||||
users.filter((u) => u.role === "admin").length ===
|
||||
1)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
1
|
||||
? "Cannot delete the last admin user"
|
||||
: "Delete user"
|
||||
}
|
||||
disabled={
|
||||
user.id === currentUser?.id ||
|
||||
(user.role === "admin" &&
|
||||
users.filter((u) => u.role === "admin").length === 1)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop Table Layout */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Last Login
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="7" className="px-6 py-12 text-center">
|
||||
<User className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
No users found
|
||||
</p>
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
Click "Add User" to create the first user
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{users.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<div className="h-10 w-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{user.username}
|
||||
</div>
|
||||
{user.id === currentUser?.id && (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center text-sm text-secondary-500 dark:text-secondary-300">
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
{user.email}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium ${
|
||||
user.role === "admin"
|
||||
? "bg-primary-100 text-primary-800"
|
||||
: user.role === "host_manager"
|
||||
? "bg-green-100 text-green-800"
|
||||
: user.role === "readonly"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-secondary-100 text-secondary-800"
|
||||
}`}
|
||||
>
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
{user.role.charAt(0).toUpperCase() +
|
||||
user.role.slice(1).replace("_", " ")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{user.is_active ? (
|
||||
<div className="flex items-center text-green-600">
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
<span className="text-sm">Active</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-red-600">
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
<span className="text-sm">Inactive</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center text-sm text-secondary-500 dark:text-secondary-300">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{user.last_login ? (
|
||||
new Date(user.last_login).toLocaleDateString()
|
||||
) : (
|
||||
<span className="text-secondary-400">Never</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEditUser(user)}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
title="Edit user"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResetPassword(user)}
|
||||
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed"
|
||||
title={
|
||||
!user.is_active
|
||||
? "Cannot reset password for inactive user"
|
||||
: "Reset password"
|
||||
}
|
||||
disabled={!user.is_active}
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleDeleteUser(user.id, user.username)
|
||||
}
|
||||
className="text-danger-400 hover:text-danger-600 dark:text-danger-500 dark:hover:text-danger-400 disabled:text-gray-300 disabled:cursor-not-allowed"
|
||||
title={
|
||||
user.id === currentUser?.id
|
||||
? "Cannot delete your own account"
|
||||
: user.role === "admin" &&
|
||||
users.filter((u) => u.role === "admin")
|
||||
.length === 1
|
||||
? "Cannot delete the last admin user"
|
||||
: "Delete user"
|
||||
}
|
||||
disabled={
|
||||
user.id === currentUser?.id ||
|
||||
(user.role === "admin" &&
|
||||
users.filter((u) => u.role === "admin")
|
||||
.length === 1)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-12 text-center">
|
||||
<User className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
No users found
|
||||
</p>
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
Click "Add User" to create the first user
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add User Modal */}
|
||||
|
||||
@@ -459,7 +459,7 @@ const Automation = () => {
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Scheduled Tasks Card */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
@@ -554,7 +554,7 @@ const Automation = () => {
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="card p-6">
|
||||
<div className="card p-4 md:p-6">
|
||||
{overviewLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
@@ -563,136 +563,238 @@ const Automation = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Run
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Task
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
onClick={() => handleSort("schedule")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Frequency
|
||||
{getSortIcon("schedule")}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
onClick={() => handleSort("lastRunTimestamp")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Last Run
|
||||
{getSortIcon("lastRunTimestamp")}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
onClick={() => handleSort("nextRunTimestamp")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Next Run
|
||||
{getSortIcon("nextRunTimestamp")}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Status
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{sortedAutomations.map((automation) => (
|
||||
<tr
|
||||
key={automation.queue}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
{automation.schedule !== "Manual only" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (automation.queue.includes("github")) {
|
||||
triggerManualJob("github");
|
||||
} else if (automation.queue.includes("session")) {
|
||||
triggerManualJob("sessions");
|
||||
} else if (
|
||||
automation.queue.includes("orphaned-repo")
|
||||
) {
|
||||
triggerManualJob("orphaned-repos");
|
||||
} else if (
|
||||
automation.queue.includes("orphaned-package")
|
||||
) {
|
||||
triggerManualJob("orphaned-packages");
|
||||
} else if (
|
||||
automation.queue.includes("docker-inventory")
|
||||
) {
|
||||
triggerManualJob("docker-inventory");
|
||||
} else if (
|
||||
automation.queue.includes("agent-commands")
|
||||
) {
|
||||
triggerManualJob("agent-collection");
|
||||
} else if (
|
||||
automation.queue.includes("system-statistics")
|
||||
) {
|
||||
triggerManualJob("system-statistics");
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center justify-center w-6 h-6 border border-transparent rounded text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors duration-200"
|
||||
title="Run Now"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">Manual</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{automation.name}
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<>
|
||||
{/* Mobile Card Layout */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{sortedAutomations.map((automation) => (
|
||||
<div key={automation.queue} className="card p-4 space-y-3">
|
||||
{/* Task Name and Run Button */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold text-secondary-900 dark:text-white">
|
||||
{automation.name}
|
||||
</div>
|
||||
{automation.description && (
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-400 mt-1">
|
||||
{automation.description}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{automation.schedule}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{automation.lastRun}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{getNextRunTime(
|
||||
automation.schedule,
|
||||
automation.lastRun,
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
{getStatusBadge(automation.status)}
|
||||
</td>
|
||||
</div>
|
||||
{automation.schedule !== "Manual only" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (automation.queue.includes("github")) {
|
||||
triggerManualJob("github");
|
||||
} else if (automation.queue.includes("session")) {
|
||||
triggerManualJob("sessions");
|
||||
} else if (
|
||||
automation.queue.includes("orphaned-repo")
|
||||
) {
|
||||
triggerManualJob("orphaned-repos");
|
||||
} else if (
|
||||
automation.queue.includes("orphaned-package")
|
||||
) {
|
||||
triggerManualJob("orphaned-packages");
|
||||
} else if (
|
||||
automation.queue.includes("docker-inventory")
|
||||
) {
|
||||
triggerManualJob("docker-inventory");
|
||||
} else if (
|
||||
automation.queue.includes("agent-commands")
|
||||
) {
|
||||
triggerManualJob("agent-collection");
|
||||
} else if (
|
||||
automation.queue.includes("system-statistics")
|
||||
) {
|
||||
triggerManualJob("system-statistics");
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center justify-center w-8 h-8 border border-transparent rounded text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors duration-200 flex-shrink-0"
|
||||
title="Run Now"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-secondary-400 dark:text-secondary-500 flex-shrink-0">
|
||||
Manual
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>{getStatusBadge(automation.status)}</div>
|
||||
|
||||
{/* Schedule and Run Times */}
|
||||
<div className="space-y-2 pt-2 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-secondary-500 dark:text-secondary-400">
|
||||
Frequency:
|
||||
</span>
|
||||
<span className="text-secondary-900 dark:text-white font-medium">
|
||||
{automation.schedule}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-secondary-500 dark:text-secondary-400">
|
||||
Last Run:
|
||||
</span>
|
||||
<span className="text-secondary-900 dark:text-white">
|
||||
{automation.lastRun}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-secondary-500 dark:text-secondary-400">
|
||||
Next Run:
|
||||
</span>
|
||||
<span className="text-secondary-900 dark:text-white">
|
||||
{getNextRunTime(
|
||||
automation.schedule,
|
||||
automation.lastRun,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop Table Layout */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Run
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Task
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
onClick={() => handleSort("schedule")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Frequency
|
||||
{getSortIcon("schedule")}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
onClick={() => handleSort("lastRunTimestamp")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Last Run
|
||||
{getSortIcon("lastRunTimestamp")}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
onClick={() => handleSort("nextRunTimestamp")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Next Run
|
||||
{getSortIcon("nextRunTimestamp")}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Status
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{sortedAutomations.map((automation) => (
|
||||
<tr
|
||||
key={automation.queue}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
{automation.schedule !== "Manual only" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (automation.queue.includes("github")) {
|
||||
triggerManualJob("github");
|
||||
} else if (
|
||||
automation.queue.includes("session")
|
||||
) {
|
||||
triggerManualJob("sessions");
|
||||
} else if (
|
||||
automation.queue.includes("orphaned-repo")
|
||||
) {
|
||||
triggerManualJob("orphaned-repos");
|
||||
} else if (
|
||||
automation.queue.includes("orphaned-package")
|
||||
) {
|
||||
triggerManualJob("orphaned-packages");
|
||||
} else if (
|
||||
automation.queue.includes("docker-inventory")
|
||||
) {
|
||||
triggerManualJob("docker-inventory");
|
||||
} else if (
|
||||
automation.queue.includes("agent-commands")
|
||||
) {
|
||||
triggerManualJob("agent-collection");
|
||||
} else if (
|
||||
automation.queue.includes("system-statistics")
|
||||
) {
|
||||
triggerManualJob("system-statistics");
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center justify-center w-6 h-6 border border-transparent rounded text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors duration-200"
|
||||
title="Run Now"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">
|
||||
Manual
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{automation.name}
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
{automation.description}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{automation.schedule}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{automation.lastRun}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{getNextRunTime(
|
||||
automation.schedule,
|
||||
automation.lastRun,
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
{getStatusBadge(automation.status)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
Users,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
@@ -59,6 +58,7 @@ const Dashboard = () => {
|
||||
const [packageTrendsHost, setPackageTrendsHost] = useState("all"); // host filter
|
||||
const [systemStatsJobId, setSystemStatsJobId] = useState(null); // Track job ID for system statistics
|
||||
const [isTriggeringJob, setIsTriggeringJob] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 640);
|
||||
const navigate = useNavigate();
|
||||
const { isDark } = useTheme();
|
||||
const { user } = useAuth();
|
||||
@@ -312,6 +312,15 @@ const Dashboard = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track window size for responsive chart options
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < 640);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
// Helper function to check if a card should be displayed
|
||||
const isCardEnabled = (cardId) => {
|
||||
const card = cardPreferences.find((c) => c.cardId === cardId);
|
||||
@@ -358,11 +367,11 @@ const Dashboard = () => {
|
||||
const getGroupClassName = (cardType) => {
|
||||
switch (cardType) {
|
||||
case "stats":
|
||||
return "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4";
|
||||
return "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4";
|
||||
case "charts":
|
||||
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
|
||||
return "grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6";
|
||||
case "widecharts":
|
||||
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
|
||||
return "grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6";
|
||||
case "fullwidth":
|
||||
return "space-y-6";
|
||||
default:
|
||||
@@ -377,7 +386,7 @@ const Dashboard = () => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||
onClick={handleNeedsRebootClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -405,7 +414,7 @@ const Dashboard = () => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||
onClick={handleTotalHostsClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -434,7 +443,7 @@ const Dashboard = () => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||
onClick={handleHostsNeedingUpdatesClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -463,7 +472,7 @@ const Dashboard = () => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||
onClick={handleUpToDateClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -492,7 +501,7 @@ const Dashboard = () => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||
onClick={handleOutdatedPackagesClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -521,7 +530,7 @@ const Dashboard = () => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||
onClick={handleSecurityUpdatesClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -550,7 +559,7 @@ const Dashboard = () => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||
onClick={handleHostGroupsClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -579,7 +588,7 @@ const Dashboard = () => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||
onClick={handleUsersClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -608,7 +617,7 @@ const Dashboard = () => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||
onClick={handleRepositoriesClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -741,7 +750,7 @@ const Dashboard = () => {
|
||||
|
||||
case "osDistribution":
|
||||
return (
|
||||
<div className="card p-6 w-full">
|
||||
<div className="card p-4 sm:p-6 w-full">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
OS Distribution
|
||||
</h3>
|
||||
@@ -755,7 +764,7 @@ const Dashboard = () => {
|
||||
|
||||
case "osDistributionDoughnut":
|
||||
return (
|
||||
<div className="card p-6 w-full">
|
||||
<div className="card p-4 sm:p-6 w-full">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
OS Distribution
|
||||
</h3>
|
||||
@@ -769,7 +778,7 @@ const Dashboard = () => {
|
||||
|
||||
case "osDistributionBar":
|
||||
return (
|
||||
<div className="card p-6 w-full">
|
||||
<div className="card p-4 sm:p-6 w-full">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
OS Distribution
|
||||
</h3>
|
||||
@@ -783,7 +792,7 @@ const Dashboard = () => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
className="card p-4 sm:p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
onClick={handleUpdateStatusClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -808,7 +817,7 @@ const Dashboard = () => {
|
||||
|
||||
case "packagePriority":
|
||||
return (
|
||||
<div className="card p-6 w-full">
|
||||
<div className="card p-4 sm:p-6 w-full">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Outdated Packages by Priority
|
||||
</h3>
|
||||
@@ -825,13 +834,13 @@ const Dashboard = () => {
|
||||
|
||||
case "packageTrends":
|
||||
return (
|
||||
<div className="card p-6 w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="card p-4 sm:p-6 w-full">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Package Trends Over Time
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3">
|
||||
{/* Refresh Button */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -869,7 +878,7 @@ const Dashboard = () => {
|
||||
}
|
||||
}}
|
||||
disabled={packageTrendsFetching || isTriggeringJob}
|
||||
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
className="px-3 py-2.5 sm:py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 min-h-[44px]"
|
||||
title={
|
||||
packageTrendsHost === "all"
|
||||
? "Trigger system statistics collection"
|
||||
@@ -890,7 +899,7 @@ const Dashboard = () => {
|
||||
<select
|
||||
value={packageTrendsPeriod}
|
||||
onChange={(e) => setPackageTrendsPeriod(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
className="px-3 py-2.5 sm:py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 min-h-[44px]"
|
||||
>
|
||||
<option value="1">Last 24 hours</option>
|
||||
<option value="7">Last 7 days</option>
|
||||
@@ -908,7 +917,7 @@ const Dashboard = () => {
|
||||
// Clear job ID message when host selection changes
|
||||
setSystemStatsJobId(null);
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
className="px-3 py-2.5 sm:py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 min-h-[44px]"
|
||||
>
|
||||
<option value="all">All Hosts</option>
|
||||
{packageTrendsData?.hosts?.length > 0 ? (
|
||||
@@ -928,7 +937,7 @@ const Dashboard = () => {
|
||||
</div>
|
||||
{/* Job ID Message */}
|
||||
{systemStatsJobId && packageTrendsHost === "all" && (
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 ml-1">
|
||||
<p className="text-xs text-secondary-600 dark:text-white/70 ml-1">
|
||||
Ran collection job #{systemStatsJobId}
|
||||
</p>
|
||||
)}
|
||||
@@ -946,7 +955,7 @@ const Dashboard = () => {
|
||||
options={packageTrendsChartOptions}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-secondary-500 dark:text-secondary-400">
|
||||
<div className="flex items-center justify-center h-full text-secondary-500 dark:text-white/70">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
@@ -984,7 +993,7 @@ const Dashboard = () => {
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div className="card p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
System Overview
|
||||
@@ -995,10 +1004,10 @@ const Dashboard = () => {
|
||||
<div className="text-2xl font-bold text-primary-600">
|
||||
{updatePercentage}%
|
||||
</div>
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
<div className="text-sm text-secondary-500 dark:text-white/70">
|
||||
Need Updates
|
||||
</div>
|
||||
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
||||
<div className="text-xs text-secondary-400 dark:text-white/60">
|
||||
{stats.cards.hostsNeedingUpdates}/{stats.cards.totalHosts}{" "}
|
||||
hosts
|
||||
</div>
|
||||
@@ -1007,10 +1016,10 @@ const Dashboard = () => {
|
||||
<div className="text-2xl font-bold text-danger-600">
|
||||
{stats.cards.securityUpdates}
|
||||
</div>
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
<div className="text-sm text-secondary-500 dark:text-white/70">
|
||||
Security Issues
|
||||
</div>
|
||||
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
||||
<div className="text-xs text-secondary-400 dark:text-white/60">
|
||||
{securityPercentage}% of updates
|
||||
</div>
|
||||
</div>
|
||||
@@ -1018,10 +1027,10 @@ const Dashboard = () => {
|
||||
<div className="text-2xl font-bold text-success-600">
|
||||
{onlinePercentage}%
|
||||
</div>
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
<div className="text-sm text-secondary-500 dark:text-white/70">
|
||||
Online
|
||||
</div>
|
||||
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
||||
<div className="text-xs text-secondary-400 dark:text-white/60">
|
||||
{onlineHosts}/{stats.cards.totalHosts} hosts
|
||||
</div>
|
||||
</div>
|
||||
@@ -1029,10 +1038,10 @@ const Dashboard = () => {
|
||||
<div className="text-2xl font-bold text-secondary-600">
|
||||
{avgPackagesPerHost}
|
||||
</div>
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
<div className="text-sm text-secondary-500 dark:text-white/70">
|
||||
Avg per Host
|
||||
</div>
|
||||
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
||||
<div className="text-xs text-secondary-400 dark:text-white/60">
|
||||
outdated packages
|
||||
</div>
|
||||
</div>
|
||||
@@ -1043,7 +1052,7 @@ const Dashboard = () => {
|
||||
|
||||
case "recentUsers":
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div className="card p-4 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Recent Users Logged in
|
||||
</h3>
|
||||
@@ -1057,7 +1066,7 @@ const Dashboard = () => {
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{u.username}
|
||||
</div>
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<div className="text-sm text-secondary-500 dark:text-white/70">
|
||||
{u.last_login
|
||||
? formatRelativeTime(u.last_login)
|
||||
: "Never"}
|
||||
@@ -1065,7 +1074,7 @@ const Dashboard = () => {
|
||||
</div>
|
||||
))}
|
||||
{(!recentUsers || recentUsers.length === 0) && (
|
||||
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
|
||||
<div className="text-center text-secondary-500 dark:text-white/70 py-4">
|
||||
No users found
|
||||
</div>
|
||||
)}
|
||||
@@ -1076,7 +1085,7 @@ const Dashboard = () => {
|
||||
|
||||
case "recentCollection":
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div className="card p-4 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Recent Collection
|
||||
</h3>
|
||||
@@ -1094,7 +1103,7 @@ const Dashboard = () => {
|
||||
>
|
||||
{host.friendly_name || host.hostname}
|
||||
</button>
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<div className="text-sm text-secondary-500 dark:text-white/70">
|
||||
{host.last_update
|
||||
? formatRelativeTime(host.last_update)
|
||||
: "Never"}
|
||||
@@ -1102,7 +1111,7 @@ const Dashboard = () => {
|
||||
</div>
|
||||
))}
|
||||
{(!recentCollection || recentCollection.length === 0) && (
|
||||
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
|
||||
<div className="text-center text-secondary-500 dark:text-white/70 py-4">
|
||||
No hosts found
|
||||
</div>
|
||||
)}
|
||||
@@ -1154,13 +1163,13 @@ const Dashboard = () => {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "right",
|
||||
position: isMobile ? "bottom" : "right",
|
||||
labels: {
|
||||
color: isDark ? "#ffffff" : "#374151",
|
||||
font: {
|
||||
size: 12,
|
||||
size: isMobile ? 10 : 12,
|
||||
},
|
||||
padding: 15,
|
||||
padding: isMobile ? 10 : 15,
|
||||
usePointStyle: true,
|
||||
pointStyle: "circle",
|
||||
},
|
||||
@@ -1168,7 +1177,7 @@ const Dashboard = () => {
|
||||
},
|
||||
layout: {
|
||||
padding: {
|
||||
right: 20,
|
||||
right: isMobile ? 10 : 20,
|
||||
},
|
||||
},
|
||||
onClick: handleOSChartClick,
|
||||
@@ -1179,13 +1188,13 @@ const Dashboard = () => {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "right",
|
||||
position: isMobile ? "bottom" : "right",
|
||||
labels: {
|
||||
color: isDark ? "#ffffff" : "#374151",
|
||||
font: {
|
||||
size: 12,
|
||||
size: isMobile ? 10 : 12,
|
||||
},
|
||||
padding: 15,
|
||||
padding: isMobile ? 10 : 15,
|
||||
usePointStyle: true,
|
||||
pointStyle: "circle",
|
||||
},
|
||||
@@ -1193,7 +1202,7 @@ const Dashboard = () => {
|
||||
},
|
||||
layout: {
|
||||
padding: {
|
||||
right: 20,
|
||||
right: isMobile ? 10 : 20,
|
||||
},
|
||||
},
|
||||
onClick: handleOSChartClick,
|
||||
@@ -1584,10 +1593,10 @@ const Dashboard = () => {
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
Welcome back, {user?.first_name || user?.username || "User"} 👋
|
||||
</h1>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
<p className="text-sm text-secondary-600 dark:text-white/80 mt-1">
|
||||
Overview of your PatchMon infrastructure
|
||||
</p>
|
||||
</div>
|
||||
@@ -1595,7 +1604,7 @@ const Dashboard = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSettingsModal(true)}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
className="hidden md:flex btn-outline items-center gap-2"
|
||||
title="Customize dashboard layout"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
@@ -1604,7 +1613,7 @@ const Dashboard = () => {
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
className="btn-outline flex items-center gap-2 min-h-[44px] min-w-[44px] justify-center"
|
||||
title="Refresh dashboard data"
|
||||
>
|
||||
<RefreshCw
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import {
|
||||
Eye as EyeIcon,
|
||||
EyeOff as EyeOffIcon,
|
||||
Filter,
|
||||
FolderPlus,
|
||||
GripVertical,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
Server,
|
||||
Square,
|
||||
Trash2,
|
||||
Users,
|
||||
Wifi,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -94,8 +94,8 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 sm:p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Add New Host
|
||||
@@ -125,7 +125,7 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, friendly_name: e.target.value })
|
||||
}
|
||||
className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200"
|
||||
className="block w-full px-3 py-3 sm:py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200 min-h-[44px]"
|
||||
placeholder="server.example.com"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
@@ -197,18 +197,18 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-2">
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-all duration-200"
|
||||
className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-all duration-200 min-h-[44px] w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-3 text-sm font-medium text-white bg-primary-600 border-2 border-transparent rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-all duration-200"
|
||||
className="px-6 py-3 text-sm font-medium text-white bg-primary-600 border-2 border-transparent rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-all duration-200 min-h-[44px] w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create Host"}
|
||||
</button>
|
||||
@@ -265,6 +265,10 @@ const Hosts = () => {
|
||||
setShowFilters(true);
|
||||
setStatusFilter("all");
|
||||
// We'll filter hosts that are stale in the filtering logic
|
||||
} else if (filter === "selected") {
|
||||
setShowFilters(true);
|
||||
setStatusFilter("all");
|
||||
// We'll filter hosts by selected hosts in the filtering logic
|
||||
} else if (showFiltersParam === "true") {
|
||||
setShowFilters(true);
|
||||
}
|
||||
@@ -714,7 +718,7 @@ const Hosts = () => {
|
||||
osFilter === "all" ||
|
||||
host.os_type?.toLowerCase() === osFilter.toLowerCase();
|
||||
|
||||
// URL filter for hosts needing updates, inactive hosts, up-to-date hosts, stale hosts, offline hosts, or reboot required
|
||||
// URL filter for hosts needing updates, inactive hosts, up-to-date hosts, stale hosts, offline hosts, reboot required, or selected hosts
|
||||
const filter = searchParams.get("filter");
|
||||
const rebootParam = searchParams.get("reboot");
|
||||
const matchesUrlFilter =
|
||||
@@ -726,6 +730,7 @@ const Hosts = () => {
|
||||
(filter !== "stale" || host.isStale) &&
|
||||
(filter !== "offline" ||
|
||||
wsStatusMap[host.api_id]?.connected !== true) &&
|
||||
(filter !== "selected" || selectedHosts.includes(host.id)) &&
|
||||
(!rebootParam || host.needs_reboot === true);
|
||||
|
||||
// Hide stale filter
|
||||
@@ -841,6 +846,7 @@ const Hosts = () => {
|
||||
searchParams,
|
||||
hideStale,
|
||||
wsStatusMap,
|
||||
selectedHosts,
|
||||
]);
|
||||
|
||||
// Get unique OS types from hosts for dynamic dropdown
|
||||
@@ -1121,12 +1127,16 @@ const Hosts = () => {
|
||||
: "Agent not connected"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full mr-1.5 ${
|
||||
wsStatus.connected ? "bg-green-500 animate-pulse" : "bg-red-500"
|
||||
}`}
|
||||
></div>
|
||||
{wsStatus.connected ? (wsStatus.secure ? "WSS" : "WS") : "Offline"}
|
||||
{wsStatus.connected && (
|
||||
<div className="w-2 h-2 rounded-full mr-1.5 bg-green-500 animate-pulse"></div>
|
||||
)}
|
||||
<span>
|
||||
{wsStatus.connected
|
||||
? wsStatus.secure
|
||||
? "WSS"
|
||||
: "WS"
|
||||
: "Offline"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1301,14 +1311,14 @@ const Hosts = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex flex-col md:h-[calc(100vh-7rem)] md:overflow-hidden">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
Hosts
|
||||
</h1>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
<p className="text-sm text-secondary-600 dark:text-white/80 mt-1">
|
||||
Manage and monitor your connected hosts
|
||||
</p>
|
||||
</div>
|
||||
@@ -1336,7 +1346,7 @@ const Hosts = () => {
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
|
||||
@@ -1420,7 +1430,7 @@ const Hosts = () => {
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{connectedCount}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 hidden sm:inline">
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
@@ -1429,7 +1439,7 @@ const Hosts = () => {
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{offlineCount}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 hidden sm:inline">
|
||||
Offline
|
||||
</span>
|
||||
</div>
|
||||
@@ -1442,37 +1452,39 @@ const Hosts = () => {
|
||||
</div>
|
||||
|
||||
{/* Hosts List */}
|
||||
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
<div className="flex items-center justify-end mb-4">
|
||||
<div className="card flex-1 flex flex-col md:overflow-hidden min-h-0">
|
||||
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col md:overflow-hidden min-h-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-end gap-3 mb-4">
|
||||
{selectedHosts.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-secondary-600">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<span className="text-sm text-secondary-600 dark:text-white/80 flex-shrink-0">
|
||||
{selectedHosts.length} host
|
||||
{selectedHosts.length !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBulkAssignModal(true)}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
className="btn-outline flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
Assign to Group
|
||||
<FolderPlus className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Assign to Group</span>
|
||||
<span className="sm:hidden">Assign</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBulkDeleteModal(true)}
|
||||
className="btn-danger flex items-center gap-2"
|
||||
className="btn-danger flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
<Trash2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedHosts([])}
|
||||
className="text-sm text-secondary-500 hover:text-secondary-700"
|
||||
className="text-xs sm:text-sm text-secondary-500 dark:text-white/70 hover:text-secondary-700 dark:hover:text-white/90 min-h-[44px] px-2"
|
||||
>
|
||||
Clear Selection
|
||||
<span className="hidden sm:inline">Clear Selection</span>
|
||||
<span className="sm:hidden">Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1494,28 +1506,28 @@ const Hosts = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`btn-outline flex items-center gap-2 ${showFilters ? "bg-primary-50 border-primary-300" : ""}`}
|
||||
className={`btn-outline flex items-center gap-1.5 sm:gap-2 px-2 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm ${showFilters ? "bg-primary-50 border-primary-300" : ""}`}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Filters
|
||||
<Filter className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Filters</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowColumnSettings(true)}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
className="btn-outline flex items-center gap-1.5 sm:gap-2 px-2 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm"
|
||||
>
|
||||
<Columns className="h-4 w-4" />
|
||||
Columns
|
||||
<Columns className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Columns</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.value)}
|
||||
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-2 py-2 pr-6 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors min-w-[120px]"
|
||||
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-2 py-2 pr-6 text-xs sm:text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors min-w-[100px] sm:min-w-[120px] min-h-[44px]"
|
||||
>
|
||||
<option value="none">No Grouping</option>
|
||||
<option value="group">By Group</option>
|
||||
@@ -1527,18 +1539,18 @@ const Hosts = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHideStale(!hideStale)}
|
||||
className={`btn-outline flex items-center gap-2 ${hideStale ? "bg-primary-50 border-primary-300" : ""}`}
|
||||
className={`btn-outline flex items-center gap-1.5 sm:gap-2 px-2 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm ${hideStale ? "bg-primary-50 border-primary-300" : ""}`}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Hide Stale
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Hide Stale</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
{showFilters && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg border dark:border-secondary-600">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-3 sm:p-4 rounded-lg border dark:border-secondary-600">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={hostGroupFilterId}
|
||||
@@ -1550,7 +1562,7 @@ const Hosts = () => {
|
||||
id={hostGroupFilterId}
|
||||
value={groupFilter}
|
||||
onChange={(e) => setGroupFilter(e.target.value)}
|
||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2.5 sm:py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white min-h-[44px]"
|
||||
>
|
||||
<option value="all">All Groups</option>
|
||||
<option value="ungrouped">Ungrouped</option>
|
||||
@@ -1572,7 +1584,7 @@ const Hosts = () => {
|
||||
id={statusFilterId}
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2.5 sm:py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white min-h-[44px]"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
@@ -1592,7 +1604,7 @@ const Hosts = () => {
|
||||
id={osFilterId}
|
||||
value={osFilter}
|
||||
onChange={(e) => setOsFilter(e.target.value)}
|
||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2.5 sm:py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white min-h-[44px]"
|
||||
>
|
||||
<option value="all">All OS</option>
|
||||
{uniqueOsTypes.map((osType) => (
|
||||
@@ -1613,7 +1625,7 @@ const Hosts = () => {
|
||||
setGroupBy("none");
|
||||
setHideStale(false);
|
||||
}}
|
||||
className="btn-outline w-full"
|
||||
className="btn-outline w-full min-h-[44px]"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
@@ -1623,7 +1635,7 @@ const Hosts = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex-1 md:overflow-hidden">
|
||||
{!hosts || hosts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
@@ -1644,7 +1656,7 @@ const Hosts = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="md:h-full overflow-auto">
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedHosts).map(
|
||||
([groupName, groupHosts]) => (
|
||||
@@ -1658,15 +1670,236 @@ const Hosts = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table for this group */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{/* Mobile Card Layout */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{groupHosts.map((host) => {
|
||||
const isInactive =
|
||||
(host.effectiveStatus || host.status) ===
|
||||
"inactive";
|
||||
const isSelected = selectedHosts.includes(host.id);
|
||||
const wsStatus = wsStatusMap[host.api_id];
|
||||
const groupIds =
|
||||
host.host_group_memberships?.map(
|
||||
(membership) => membership.host_groups.id,
|
||||
) || [];
|
||||
const groups =
|
||||
hostGroups?.filter((g) =>
|
||||
groupIds.includes(g.id),
|
||||
) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={`card p-4 space-y-3 ${
|
||||
isSelected
|
||||
? "ring-2 ring-primary-500 bg-primary-50 dark:bg-primary-900/20"
|
||||
: isInactive
|
||||
? "bg-red-50 dark:bg-red-900/20"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* Header with select and main info */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "select",
|
||||
) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleSelectHost(host.id)
|
||||
}
|
||||
className="flex-shrink-0 min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare className="h-5 w-5 text-primary-600" />
|
||||
) : (
|
||||
<Square className="h-5 w-5 text-secondary-400" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "host",
|
||||
) && (
|
||||
<Link
|
||||
to={`/hosts/${host.id}`}
|
||||
className="text-base font-semibold text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 block truncate"
|
||||
>
|
||||
{host.friendly_name || "Unnamed Host"}
|
||||
</Link>
|
||||
)}
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "hostname",
|
||||
) &&
|
||||
host.hostname && (
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-400 font-mono truncate">
|
||||
{host.hostname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "actions",
|
||||
) && (
|
||||
<Link
|
||||
to={`/hosts/${host.id}`}
|
||||
className="btn-primary text-sm px-3 py-2 min-h-[44px] flex items-center gap-1 flex-shrink-0"
|
||||
>
|
||||
View
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OS, Status and connection info */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "os",
|
||||
) && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<OSIcon
|
||||
osType={host.os_type}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-secondary-700 dark:text-secondary-300">
|
||||
{getOSDisplayName(host.os_type)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "status",
|
||||
) && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300">
|
||||
{(host.effectiveStatus || host.status)
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
(
|
||||
host.effectiveStatus || host.status
|
||||
).slice(1)}
|
||||
</span>
|
||||
)}
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "ws_status",
|
||||
) &&
|
||||
wsStatus && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${
|
||||
wsStatus.connected
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{wsStatus.connected && (
|
||||
<div className="w-2 h-2 rounded-full mr-1.5 bg-green-500 animate-pulse"></div>
|
||||
)}
|
||||
<span>
|
||||
{wsStatus.connected
|
||||
? wsStatus.secure
|
||||
? "WSS"
|
||||
: "WS"
|
||||
: "Offline"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reboot Required */}
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "needs_reboot",
|
||||
) &&
|
||||
host.needs_reboot && (
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reboot Required
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group info */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "group",
|
||||
) &&
|
||||
groups.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className="text-secondary-500 dark:text-secondary-400">
|
||||
Groups:
|
||||
</span>
|
||||
{groups.map((g, idx) => (
|
||||
<span
|
||||
key={g.id}
|
||||
className="text-secondary-700 dark:text-secondary-300"
|
||||
>
|
||||
{g.name}
|
||||
{idx < groups.length - 1 ? "," : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Updates info */}
|
||||
<div className="flex items-center gap-4 pt-2 border-t border-secondary-200 dark:border-secondary-600">
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "updates",
|
||||
) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/packages?host=${host.id}&filter=outdated`,
|
||||
)
|
||||
}
|
||||
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 font-medium min-h-[44px] flex items-center"
|
||||
>
|
||||
{host.updatesCount || 0} Updates
|
||||
</button>
|
||||
)}
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "security_updates",
|
||||
) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/packages?host=${host.id}&filter=security-updates`,
|
||||
)
|
||||
}
|
||||
className="text-sm text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 font-medium min-h-[44px] flex items-center"
|
||||
>
|
||||
{host.securityUpdatesCount || 0} Security
|
||||
</button>
|
||||
)}
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "last_update",
|
||||
) && (
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400 ml-auto">
|
||||
Updated{" "}
|
||||
{formatRelativeTime(host.last_update)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop Table Layout */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table
|
||||
className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600"
|
||||
style={{ minWidth: "max-content" }}
|
||||
>
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
{visibleColumns.map((column) => (
|
||||
<th
|
||||
key={column.id}
|
||||
className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
|
||||
className="px-3 sm:px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider whitespace-nowrap"
|
||||
>
|
||||
{column.id === "select" ? (
|
||||
<button
|
||||
@@ -1842,7 +2075,7 @@ const Hosts = () => {
|
||||
{visibleColumns.map((column) => (
|
||||
<td
|
||||
key={column.id}
|
||||
className="px-4 py-2 whitespace-nowrap text-center"
|
||||
className="px-3 sm:px-4 py-2 whitespace-nowrap text-center"
|
||||
>
|
||||
{renderCellContent(column, host)}
|
||||
</td>
|
||||
@@ -2184,7 +2417,6 @@ const ColumnSettingsModal = ({
|
||||
key={column.id}
|
||||
type="button"
|
||||
draggable
|
||||
tabIndex={0}
|
||||
aria-label={`Drag to reorder ${column.label} column`}
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -2195,7 +2427,7 @@ const ColumnSettingsModal = ({
|
||||
// Focus handling for keyboard users
|
||||
}
|
||||
}}
|
||||
className={`flex items-center justify-between p-2.5 border rounded-lg cursor-move w-full text-left transition-colors ${
|
||||
className={`flex items-center justify-between p-2.5 border rounded-lg cursor-move w-full transition-colors ${
|
||||
draggedIndex === index
|
||||
? "opacity-50"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
@@ -2209,12 +2441,20 @@ const ColumnSettingsModal = ({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleVisibility(column.id)}
|
||||
className={`p-1 rounded transition-colors flex-shrink-0 ${
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVisibility(column.id);
|
||||
}}
|
||||
className={`p-1 rounded transition-colors flex-shrink-0 min-w-[44px] min-h-[44px] flex items-center justify-center ${
|
||||
column.visible
|
||||
? "text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
: "text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
}`}
|
||||
aria-label={
|
||||
column.visible
|
||||
? `Hide ${column.label} column`
|
||||
: `Show ${column.label} column`
|
||||
}
|
||||
>
|
||||
{column.visible ? (
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
ChartColumnBig,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Package,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@@ -150,28 +148,41 @@ const PackageDetail = () => {
|
||||
const stats = packageData.stats || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="flex items-center gap-2 sm:gap-4 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/packages")}
|
||||
className="flex items-center gap-2 text-secondary-600 hover:text-secondary-900 dark:text-secondary-400 dark:hover:text-white transition-colors"
|
||||
className="flex items-center gap-2 text-secondary-600 hover:text-secondary-900 dark:text-secondary-400 dark:hover:text-white transition-colors text-sm sm:text-base"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Packages
|
||||
<span className="hidden sm:inline">Back to Packages</span>
|
||||
<span className="sm:hidden">Back</span>
|
||||
</button>
|
||||
<ChevronRight className="h-4 w-4 text-secondary-400" />
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
<ChevronRight className="h-4 w-4 text-secondary-400 hidden sm:block" />
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-secondary-900 dark:text-white truncate">
|
||||
{pkg.name}
|
||||
</h1>
|
||||
{stats.updatesNeeded > 0 ? (
|
||||
stats.securityUpdates > 0 ? (
|
||||
<span className="badge-danger flex items-center gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
Security Update Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning">Update Available</span>
|
||||
)
|
||||
) : (
|
||||
<span className="badge-success">Up to Date</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoadingPackage || isLoadingHosts}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
className="btn-outline flex items-center gap-2 text-sm sm:text-base self-start sm:self-auto"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${
|
||||
@@ -182,115 +193,64 @@ const PackageDetail = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Package Overview */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Package Info */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<Package className="h-8 w-8 text-primary-600 flex-shrink-0 mt-1" />
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white mb-2">
|
||||
{pkg.name}
|
||||
</h2>
|
||||
{pkg.description && (
|
||||
<p className="text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
{pkg.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{pkg.category && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-secondary-400" />
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Category: {pkg.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{pkg.latest_version && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-secondary-400" />
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Latest: {pkg.latest_version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{pkg.updated_at && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-secondary-400" />
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Updated: {formatRelativeTime(pkg.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="mb-4">
|
||||
{stats.updatesNeeded > 0 ? (
|
||||
stats.securityUpdates > 0 ? (
|
||||
<span className="badge-danger flex items-center gap-1 w-fit">
|
||||
<Shield className="h-3 w-3" />
|
||||
Security Update Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning w-fit">Update Available</span>
|
||||
)
|
||||
) : (
|
||||
<span className="badge-success w-fit">Up to Date</span>
|
||||
)}
|
||||
{/* Package Stats Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Latest Version */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<Download className="h-5 w-5 text-primary-600 mr-2 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Latest Version
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white truncate">
|
||||
{pkg.latest_version || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="space-y-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<ChartColumnBig className="h-5 w-5 text-primary-600" />
|
||||
<h3 className="font-medium text-secondary-900 dark:text-white">
|
||||
Installation Stats
|
||||
</h3>
|
||||
{/* Updated Date */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-5 w-5 text-primary-600 mr-2 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Updated
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{pkg.updated_at ? formatRelativeTime(pkg.updated_at) : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Total Installations
|
||||
</span>
|
||||
<span className="font-semibold text-secondary-900 dark:text-white">
|
||||
{stats.totalInstalls || 0}
|
||||
</span>
|
||||
</div>
|
||||
{stats.updatesNeeded > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Hosts Needing Updates
|
||||
</span>
|
||||
<span className="font-semibold text-warning-600">
|
||||
{stats.updatesNeeded}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.securityUpdates > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Security Updates
|
||||
</span>
|
||||
<span className="font-semibold text-danger-600">
|
||||
{stats.securityUpdates}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Up to Date
|
||||
</span>
|
||||
<span className="font-semibold text-success-600">
|
||||
{(stats.totalInstalls || 0) - (stats.updatesNeeded || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hosts with this Package */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-primary-600 mr-2 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Hosts with Package
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{stats.totalInstalls || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Up to Date */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-5 w-5 text-success-600 mr-2 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Up to Date
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{(stats.totalInstalls || 0) - (stats.updatesNeeded || 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,18 +258,9 @@ const PackageDetail = () => {
|
||||
|
||||
{/* Hosts List */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-primary-600" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Installed On Hosts ({hosts.length})
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
{/* Search */}
|
||||
<div className="relative max-w-sm">
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400" />
|
||||
<input
|
||||
type="text"
|
||||
@@ -319,7 +270,7 @@ const PackageDetail = () => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400 text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,96 +307,170 @@ const PackageDetail = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Host
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Current Version
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Last Updated
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Reboot Required
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredAndPaginatedHosts.map((host) => (
|
||||
<tr
|
||||
key={host.hostId}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 cursor-pointer transition-colors"
|
||||
onClick={() => handleHostClick(host.hostId)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<div>
|
||||
{/* Mobile Card Layout */}
|
||||
<div className="md:hidden space-y-3 p-4">
|
||||
{filteredAndPaginatedHosts.map((host) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Complex card layout requires div
|
||||
<div
|
||||
key={host.hostId}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleHostClick(host.hostId)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleHostClick(host.hostId);
|
||||
}
|
||||
}}
|
||||
className="card p-4 space-y-3 cursor-pointer"
|
||||
>
|
||||
{/* Host Name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-secondary-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold text-secondary-900 dark:text-white truncate">
|
||||
{host.friendlyName || host.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status and Version */}
|
||||
<div className="flex items-center justify-between gap-3 pt-3 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Version:
|
||||
</span>
|
||||
<span className="text-sm text-secondary-900 dark:text-white font-mono">
|
||||
{host.currentVersion || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Status:
|
||||
</span>
|
||||
{host.needsUpdate ? (
|
||||
host.isSecurityUpdate ? (
|
||||
<span className="badge-danger flex items-center gap-1 text-xs">
|
||||
<Shield className="h-3 w-3" />
|
||||
Security Update
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning text-xs">
|
||||
Update Available
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="badge-success text-xs">
|
||||
Up to Date
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-end">
|
||||
{host.needsReboot && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reboot Required
|
||||
</span>
|
||||
)}
|
||||
{host.lastUpdate && (
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
{formatRelativeTime(host.lastUpdate)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop Table Layout */}
|
||||
<div className="hidden md:block">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Host
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Current Version
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Last Updated
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Reboot Required
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredAndPaginatedHosts.map((host) => (
|
||||
<tr
|
||||
key={host.hostId}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 cursor-pointer transition-colors"
|
||||
onClick={() => handleHostClick(host.hostId)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{host.friendlyName || host.hostname}
|
||||
</div>
|
||||
{host.friendlyName && host.hostname && (
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{host.hostname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{host.currentVersion || "Unknown"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{host.needsUpdate ? (
|
||||
host.isSecurityUpdate ? (
|
||||
<span className="badge-danger flex items-center gap-1 w-fit">
|
||||
<Shield className="h-3 w-3" />
|
||||
Security Update
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{host.currentVersion || "Unknown"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{host.needsUpdate ? (
|
||||
host.isSecurityUpdate ? (
|
||||
<span className="badge-danger flex items-center gap-1 w-fit">
|
||||
<Shield className="h-3 w-3" />
|
||||
Security Update
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning w-fit">
|
||||
Update Available
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="badge-success w-fit">
|
||||
Up to Date
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{host.lastUpdate
|
||||
? formatRelativeTime(host.lastUpdate)
|
||||
: "Never"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{host.needsReboot ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Required
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning w-fit">
|
||||
Update Available
|
||||
<span className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
No
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="badge-success w-fit">
|
||||
Up to Date
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{host.lastUpdate
|
||||
? formatRelativeTime(host.lastUpdate)
|
||||
: "Never"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{host.needsReboot ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Required
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
No
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600 flex items-center justify-between">
|
||||
<div className="px-4 sm:px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<span className="text-xs sm:text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Rows per page:
|
||||
</span>
|
||||
<select
|
||||
@@ -454,30 +479,30 @@ const PackageDetail = () => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
className="text-xs sm:text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-between sm:justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
className="px-3 py-1 text-xs sm:text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<span className="text-xs sm:text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
className="px-3 py-1 text-xs sm:text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
@@ -80,17 +80,31 @@ const Packages = () => {
|
||||
};
|
||||
|
||||
// Handle hosts click (view hosts where package is installed)
|
||||
const handlePackageHostsClick = (pkg) => {
|
||||
const packageHosts = pkg.packageHosts || [];
|
||||
const hostIds = packageHosts.map((host) => host.hostId);
|
||||
const handlePackageHostsClick = async (pkg) => {
|
||||
try {
|
||||
// Fetch all hosts for this package (packageHosts may only include hosts needing updates)
|
||||
const response = await packagesAPI.getHosts(pkg.id, { limit: 1000 });
|
||||
const hosts = response.data?.hosts || [];
|
||||
const hostIds = hosts.map((host) => host.hostId).filter(Boolean);
|
||||
|
||||
// Create URL with selected hosts and filter
|
||||
const params = new URLSearchParams();
|
||||
params.set("selected", hostIds.join(","));
|
||||
params.set("filter", "selected");
|
||||
if (hostIds.length === 0) {
|
||||
// No hosts found, navigate without filter
|
||||
navigate("/hosts");
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to hosts page with selected hosts
|
||||
navigate(`/hosts?${params.toString()}`);
|
||||
// Create URL with selected hosts and filter
|
||||
const params = new URLSearchParams();
|
||||
params.set("selected", hostIds.join(","));
|
||||
params.set("filter", "selected");
|
||||
|
||||
// Navigate to hosts page with selected hosts
|
||||
navigate(`/hosts?${params.toString()}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching package hosts:", error);
|
||||
// Fallback: navigate to hosts page without filter
|
||||
navigate("/hosts");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle URL filter parameters
|
||||
@@ -505,7 +519,7 @@ const Packages = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
||||
<div className="md:h-[calc(100vh-7rem)] flex flex-col md:overflow-hidden min-h-0">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
@@ -533,7 +547,7 @@ const Packages = () => {
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-5 gap-4 mb-6 flex-shrink-0">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6 flex-shrink-0">
|
||||
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-primary-600 mr-2" />
|
||||
@@ -631,8 +645,8 @@ const Packages = () => {
|
||||
</div>
|
||||
|
||||
{/* Packages List */}
|
||||
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
<div className="card md:flex-1 flex flex-col md:overflow-hidden min-h-0">
|
||||
<div className="px-4 py-4 sm:p-4 md:flex-1 flex flex-col md:overflow-hidden min-h-0">
|
||||
<div className="flex items-center justify-end mb-4">
|
||||
{/* Empty selection controls area to match hosts page spacing */}
|
||||
</div>
|
||||
@@ -641,8 +655,8 @@ const Packages = () => {
|
||||
<div className="mb-4 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<div className="hidden md:flex flex-1">
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
|
||||
<input
|
||||
type="text"
|
||||
@@ -705,7 +719,7 @@ const Packages = () => {
|
||||
</div>
|
||||
|
||||
{/* Columns Button */}
|
||||
<div className="flex items-center">
|
||||
<div className="hidden md:flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowColumnSettings(true)}
|
||||
@@ -718,7 +732,7 @@ const Packages = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="md:flex-1 md:overflow-hidden">
|
||||
{filteredAndSortedPackages.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
@@ -735,46 +749,143 @@ const Packages = () => {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
|
||||
<tr>
|
||||
{visibleColumns.map((column) => (
|
||||
<th
|
||||
key={column.id}
|
||||
className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort(column.id)}
|
||||
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
|
||||
>
|
||||
{column.label}
|
||||
{getSortIcon(column.id)}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{paginatedPackages.map((pkg) => (
|
||||
<tr
|
||||
key={pkg.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
<>
|
||||
{/* Mobile Card Layout */}
|
||||
<div className="md:hidden space-y-3 pb-4">
|
||||
{paginatedPackages.map((pkg) => (
|
||||
<div key={pkg.id} className="card p-4 space-y-3">
|
||||
{/* Package Name */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/packages/${pkg.id}`)}
|
||||
className="text-left w-full"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-5 w-5 text-secondary-400 flex-shrink-0" />
|
||||
<div className="text-base font-semibold text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{pkg.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Status and Hosts on same line */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(() => {
|
||||
const needsUpdates =
|
||||
(pkg.stats?.updatesNeeded || 0) > 0;
|
||||
if (!needsUpdates) {
|
||||
return (
|
||||
<span className="badge-success text-xs">
|
||||
Up to Date
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return pkg.isSecurityUpdate ? (
|
||||
<span className="badge-danger text-xs flex items-center gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
Security
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning text-xs">
|
||||
Update
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePackageHostsClick(pkg)}
|
||||
className="text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded px-2 py-1 -mx-2 transition-colors"
|
||||
>
|
||||
<span className="text-secondary-500 dark:text-secondary-400">
|
||||
On:
|
||||
</span>
|
||||
<span className="text-secondary-900 dark:text-white font-semibold">
|
||||
{(() => {
|
||||
const installedHostsCount =
|
||||
pkg.packageHostsCount ||
|
||||
pkg.stats?.totalInstalls ||
|
||||
pkg.packageHosts?.length ||
|
||||
0;
|
||||
const hostsNeedingUpdates =
|
||||
pkg.stats?.updatesNeeded || 0;
|
||||
return hostsNeedingUpdates > 0 &&
|
||||
hostsNeedingUpdates < installedHostsCount
|
||||
? `${hostsNeedingUpdates}/${installedHostsCount}`
|
||||
: installedHostsCount;
|
||||
})()}
|
||||
</span>
|
||||
<span className="text-secondary-500 dark:text-secondary-400">
|
||||
{(() => {
|
||||
const installedHostsCount =
|
||||
pkg.packageHostsCount ||
|
||||
pkg.stats?.totalInstalls ||
|
||||
pkg.packageHosts?.length ||
|
||||
0;
|
||||
return ` host${installedHostsCount !== 1 ? "s" : ""}`;
|
||||
})()}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="pt-2 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<div className="text-sm">
|
||||
<span className="text-secondary-500 dark:text-secondary-400">
|
||||
Latest:
|
||||
</span>
|
||||
<span className="text-secondary-900 dark:text-white font-mono text-sm">
|
||||
{pkg.latestVersion || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop Table Layout */}
|
||||
<div className="hidden md:block h-full overflow-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
|
||||
<tr>
|
||||
{visibleColumns.map((column) => (
|
||||
<td
|
||||
<th
|
||||
key={column.id}
|
||||
className="px-4 py-2 whitespace-nowrap text-center"
|
||||
className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
|
||||
>
|
||||
{renderCellContent(column, pkg)}
|
||||
</td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort(column.id)}
|
||||
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
|
||||
>
|
||||
{column.label}
|
||||
{getSortIcon(column.id)}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{paginatedPackages.map((pkg) => (
|
||||
<tr
|
||||
key={pkg.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<td
|
||||
key={column.id}
|
||||
className="px-4 py-2 whitespace-nowrap text-center"
|
||||
>
|
||||
{renderCellContent(column, pkg)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -891,14 +1002,16 @@ const ColumnSettingsModal = ({
|
||||
|
||||
<div className="space-y-2">
|
||||
{columnConfig.map((column, index) => (
|
||||
<button
|
||||
type="button"
|
||||
// biome-ignore lint/a11y/useSemanticElements: Draggable element requires div
|
||||
<div
|
||||
key={column.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
className={`flex items-center justify-between p-3 border rounded-lg cursor-move w-full text-left ${
|
||||
className={`flex items-center justify-between p-3 border rounded-lg cursor-move w-full ${
|
||||
draggedIndex === index
|
||||
? "opacity-50"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
@@ -912,7 +1025,10 @@ const ColumnSettingsModal = ({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleVisibility(column.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVisibility(column.id);
|
||||
}}
|
||||
className={`p-1 rounded ${
|
||||
column.visible
|
||||
? "text-primary-600 hover:text-primary-700"
|
||||
@@ -925,7 +1041,7 @@ const ColumnSettingsModal = ({
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -188,32 +188,32 @@ const Profile = () => {
|
||||
</div>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg p-4 md:p-6">
|
||||
<div className="flex items-center space-x-3 md:space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User className="h-8 w-8 text-primary-600" />
|
||||
<div className="h-12 w-12 md:h-16 md:w-16 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
||||
<User className="h-6 w-6 md:h-8 md:w-8 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base md:text-lg font-medium text-secondary-900 dark:text-white truncate">
|
||||
{user?.first_name && user?.last_name
|
||||
? `${user.first_name} ${user.last_name}`
|
||||
: user?.first_name || user?.username}
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 truncate">
|
||||
{user?.email}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium ${
|
||||
user?.role === "admin"
|
||||
? "bg-primary-100 text-primary-800"
|
||||
? "bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200"
|
||||
: user?.role === "host_manager"
|
||||
? "bg-green-100 text-green-800"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: user?.role === "readonly"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-secondary-100 text-secondary-800"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
||||
: "bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200"
|
||||
}`}
|
||||
>
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
@@ -227,7 +227,35 @@ const Profile = () => {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600">
|
||||
{/* Mobile Button Navigation */}
|
||||
<div className="md:hidden p-4 space-y-2 border-b border-secondary-200 dark:border-secondary-600">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-md font-medium text-sm transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400 border border-primary-200 dark:border-primary-800"
|
||||
: "bg-secondary-50 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 border border-secondary-200 dark:border-secondary-600 hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{tab.name}</span>
|
||||
</div>
|
||||
{activeTab === tab.id && (
|
||||
<CheckCircle className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop Tab Navigation */}
|
||||
<div className="hidden md:block border-b border-secondary-200 dark:border-secondary-600">
|
||||
<nav className="-mb-px flex space-x-8 px-6">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
@@ -250,7 +278,7 @@ const Profile = () => {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="p-4 md:p-6">
|
||||
{/* Success/Error Message */}
|
||||
{message.text && (
|
||||
<div
|
||||
@@ -372,13 +400,13 @@ const Profile = () => {
|
||||
</div>
|
||||
|
||||
{/* Theme Settings */}
|
||||
<div className="border-t border-secondary-200 dark:border-secondary-600 pt-6">
|
||||
<div className="border-t border-secondary-200 dark:border-secondary-600 pt-4 md:pt-6">
|
||||
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">
|
||||
Appearance
|
||||
</h4>
|
||||
<div className="max-w-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center space-x-2 md:space-x-3 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
{isDark ? (
|
||||
<Moon className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
|
||||
@@ -386,11 +414,11 @@ const Profile = () => {
|
||||
<Sun className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{isDark ? "Dark Mode" : "Light Mode"}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
{isDark
|
||||
? "Switch to light mode"
|
||||
: "Switch to dark mode"}
|
||||
@@ -417,7 +445,7 @@ const Profile = () => {
|
||||
</div>
|
||||
|
||||
{/* Color Theme Settings */}
|
||||
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<div className="mt-4 md:mt-6 pt-4 md:pt-6 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
|
||||
Color Theme
|
||||
</h4>
|
||||
@@ -425,7 +453,7 @@ const Profile = () => {
|
||||
Choose your preferred color scheme for the application
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
|
||||
{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
|
||||
const isSelected = colorTheme === themeKey;
|
||||
const gradientColors = theme.login.xColors;
|
||||
@@ -483,7 +511,7 @@ const Profile = () => {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 w-full sm:w-auto justify-center sm:justify-end"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
@@ -607,7 +635,7 @@ const Profile = () => {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 w-full sm:w-auto justify-center sm:justify-end"
|
||||
>
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
{isLoading ? "Changing..." : "Change Password"}
|
||||
@@ -869,19 +897,19 @@ const TfaTab = () => {
|
||||
|
||||
{/* TFA Status */}
|
||||
{setupStep === "status" && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 md:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<div
|
||||
className={`p-2 rounded-full ${tfaStatus?.enabled ? "bg-green-100 dark:bg-green-900" : "bg-secondary-100 dark:bg-secondary-700"}`}
|
||||
className={`p-2 rounded-full flex-shrink-0 ${tfaStatus?.enabled ? "bg-green-100 dark:bg-green-900" : "bg-secondary-100 dark:bg-secondary-700"}`}
|
||||
>
|
||||
<Smartphone
|
||||
className={`h-6 w-6 ${tfaStatus?.enabled ? "text-green-600 dark:text-green-400" : "text-secondary-600 dark:text-secondary-400"}`}
|
||||
className={`h-5 w-5 md:h-6 md:w-6 ${tfaStatus?.enabled ? "text-green-600 dark:text-green-400" : "text-secondary-600 dark:text-secondary-400"}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-base md:text-lg font-medium text-secondary-900 dark:text-white">
|
||||
{tfaStatus?.enabled
|
||||
? "Two-Factor Authentication Enabled"
|
||||
: "Two-Factor Authentication Disabled"}
|
||||
@@ -893,12 +921,12 @@ const TfaTab = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex-shrink-0">
|
||||
{tfaStatus?.enabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupStep("disable")}
|
||||
className="btn-outline text-danger-600 border-danger-300 hover:bg-danger-50"
|
||||
className="btn-outline text-danger-600 border-danger-300 hover:bg-danger-50 w-full sm:w-auto"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Disable TFA
|
||||
@@ -908,7 +936,7 @@ const TfaTab = () => {
|
||||
type="button"
|
||||
onClick={handleSetup}
|
||||
disabled={setupMutation.isPending}
|
||||
className="btn-primary"
|
||||
className="btn-primary w-full sm:w-auto"
|
||||
>
|
||||
<Smartphone className="h-4 w-4 mr-2" />
|
||||
{setupMutation.isPending ? "Setting up..." : "Enable TFA"}
|
||||
@@ -919,8 +947,8 @@ const TfaTab = () => {
|
||||
</div>
|
||||
|
||||
{tfaStatus?.enabled && (
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 md:p-6">
|
||||
<h4 className="text-base md:text-lg font-medium text-secondary-900 dark:text-white mb-3 md:mb-4">
|
||||
Backup Codes
|
||||
</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
@@ -931,7 +959,7 @@ const TfaTab = () => {
|
||||
type="button"
|
||||
onClick={handleRegenerateBackupCodes}
|
||||
disabled={regenerateBackupCodesMutation.isPending}
|
||||
className="btn-outline"
|
||||
className="btn-outline w-full sm:w-auto"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${regenerateBackupCodesMutation.isPending ? "animate-spin" : ""}`}
|
||||
@@ -947,9 +975,9 @@ const TfaTab = () => {
|
||||
|
||||
{/* TFA Setup */}
|
||||
{setupStep === "setup" && setupMutation.data && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 md:p-6">
|
||||
<h4 className="text-base md:text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Setup Two-Factor Authentication
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
@@ -957,19 +985,19 @@ const TfaTab = () => {
|
||||
<img
|
||||
src={setupMutation.data.qrCode}
|
||||
alt="QR Code"
|
||||
className="mx-auto h-48 w-48 border border-secondary-200 dark:border-secondary-600 rounded-lg"
|
||||
className="mx-auto h-40 w-40 md:h-48 md:w-48 border border-secondary-200 dark:border-secondary-600 rounded-lg"
|
||||
/>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-2">
|
||||
Scan this QR code with your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-3 md:p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
|
||||
Manual Entry Key:
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<code className="flex-1 bg-white dark:bg-secondary-800 px-3 py-2 rounded border text-sm font-mono">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-white dark:bg-secondary-800 px-2 md:px-3 py-2 rounded border text-xs md:text-sm font-mono break-all">
|
||||
{setupMutation.data.manualEntryKey}
|
||||
</code>
|
||||
<button
|
||||
@@ -977,7 +1005,7 @@ const TfaTab = () => {
|
||||
onClick={() =>
|
||||
copyToClipboard(setupMutation.data.manualEntryKey)
|
||||
}
|
||||
className="p-2 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300"
|
||||
className="p-2 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 flex-shrink-0"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
@@ -989,7 +1017,7 @@ const TfaTab = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupStep("verify")}
|
||||
className="btn-primary"
|
||||
className="btn-primary w-full sm:w-auto"
|
||||
>
|
||||
Continue to Verification
|
||||
</button>
|
||||
@@ -1001,9 +1029,9 @@ const TfaTab = () => {
|
||||
|
||||
{/* TFA Verification */}
|
||||
{setupStep === "verify" && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 md:p-6">
|
||||
<h4 className="text-base md:text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Verify Setup
|
||||
</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
@@ -1028,18 +1056,18 @@ const TfaTab = () => {
|
||||
)
|
||||
}
|
||||
placeholder="000000"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white text-center text-lg font-mono tracking-widest"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white text-center text-base md:text-lg font-mono tracking-widest"
|
||||
maxLength="6"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
verifyMutation.isPending || verificationToken.length !== 6
|
||||
}
|
||||
className="btn-primary"
|
||||
className="btn-primary w-full sm:w-auto"
|
||||
>
|
||||
{verifyMutation.isPending
|
||||
? "Verifying..."
|
||||
@@ -1048,7 +1076,7 @@ const TfaTab = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupStep("status")}
|
||||
className="btn-outline"
|
||||
className="btn-outline w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -1060,17 +1088,17 @@ const TfaTab = () => {
|
||||
|
||||
{/* Backup Codes */}
|
||||
{setupStep === "backup-codes" && backupCodes.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 md:p-6">
|
||||
<h4 className="text-base md:text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Backup Codes
|
||||
</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
Save these backup codes in a safe place. Each code can only be
|
||||
used once.
|
||||
</p>
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg mb-4">
|
||||
<div className="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-3 md:p-4 rounded-lg mb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 font-mono text-xs md:text-sm">
|
||||
{backupCodes.map((code, index) => (
|
||||
<div
|
||||
key={code}
|
||||
@@ -1079,18 +1107,18 @@ const TfaTab = () => {
|
||||
<span className="text-secondary-600 dark:text-secondary-400">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span className="text-secondary-900 dark:text-white">
|
||||
<span className="text-secondary-900 dark:text-white break-all ml-2">
|
||||
{code}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadBackupCodes}
|
||||
className="btn-outline"
|
||||
className="btn-outline w-full sm:w-auto"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download Codes
|
||||
@@ -1101,7 +1129,7 @@ const TfaTab = () => {
|
||||
setSetupStep("status");
|
||||
queryClient.invalidateQueries(["tfaStatus"]);
|
||||
}}
|
||||
className="btn-primary"
|
||||
className="btn-primary w-full sm:w-auto"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
@@ -1112,9 +1140,9 @@ const TfaTab = () => {
|
||||
|
||||
{/* Disable TFA */}
|
||||
{setupStep === "disable" && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 md:p-6">
|
||||
<h4 className="text-base md:text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Disable Two-Factor Authentication
|
||||
</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
@@ -1137,18 +1165,18 @@ const TfaTab = () => {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disableMutation.isPending || !password}
|
||||
className="btn-danger"
|
||||
className="btn-danger w-full sm:w-auto"
|
||||
>
|
||||
{disableMutation.isPending ? "Disabling..." : "Disable TFA"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupStep("status")}
|
||||
className="btn-outline"
|
||||
className="btn-outline w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -1314,7 +1342,7 @@ const SessionsTab = () => {
|
||||
type="button"
|
||||
onClick={handleRevokeAllSessions}
|
||||
disabled={revokeAllSessionsMutation.isPending}
|
||||
className="inline-flex items-center px-4 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50"
|
||||
className="inline-flex items-center px-4 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50 w-full sm:w-auto justify-center sm:justify-end"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
{revokeAllSessionsMutation.isPending
|
||||
@@ -1328,57 +1356,57 @@ const SessionsTab = () => {
|
||||
{sessionsData.sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`border rounded-lg p-4 ${
|
||||
className={`border rounded-lg p-3 md:p-4 ${
|
||||
session.is_current_session
|
||||
? "border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-900/20"
|
||||
: "border-secondary-200 bg-white dark:border-secondary-700 dark:bg-secondary-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Monitor className="h-5 w-5 text-secondary-500" />
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start space-x-2 md:space-x-3">
|
||||
<Monitor className="h-4 w-4 md:h-5 md:w-5 text-secondary-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{session.device_info?.browser} on{" "}
|
||||
{session.device_info?.os}
|
||||
</h4>
|
||||
{session.is_current_session && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200 flex-shrink-0">
|
||||
Current Session
|
||||
</span>
|
||||
)}
|
||||
{session.tfa_remember_me && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-200">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-200 flex-shrink-0">
|
||||
Remembered
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
{session.device_info?.device} • {session.ip_address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<div className="mt-3 space-y-2 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span>
|
||||
<MapPin className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{session.location_info?.city},{" "}
|
||||
{session.location_info?.country}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<Clock className="h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
Last active: {formatRelativeTime(session.last_activity)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-xs md:text-sm">
|
||||
<span>Created: {formatDate(session.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-xs md:text-sm">
|
||||
<span>Login count: {session.login_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1389,7 +1417,7 @@ const SessionsTab = () => {
|
||||
type="button"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
disabled={revokeSessionMutation.isPending}
|
||||
className="ml-4 inline-flex items-center px-3 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50"
|
||||
className="inline-flex items-center px-3 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50 flex-shrink-0"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -293,7 +293,7 @@ const Repositories = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteModalData && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
@@ -371,7 +371,7 @@ const Repositories = () => {
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6 flex-shrink-0">
|
||||
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||
<div className="flex items-center">
|
||||
<Database className="h-5 w-5 text-primary-600 mr-2" />
|
||||
@@ -430,8 +430,8 @@ const Repositories = () => {
|
||||
</div>
|
||||
|
||||
{/* Repositories List */}
|
||||
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
<div className="card flex-1 flex flex-col md:overflow-hidden min-h-0">
|
||||
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col md:overflow-hidden min-h-0">
|
||||
<div className="flex items-center justify-end mb-4">
|
||||
{/* Empty selection controls area to match packages page spacing */}
|
||||
</div>
|
||||
@@ -534,47 +534,178 @@ const Repositories = () => {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
|
||||
<tr>
|
||||
{visibleColumns.map((column) => (
|
||||
<th
|
||||
key={column.id}
|
||||
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort(column.id)}
|
||||
className="flex items-center justify-start gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
|
||||
>
|
||||
{column.label}
|
||||
{getSortIcon(column.id)}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredAndSortedRepositories.map((repo) => (
|
||||
<tr
|
||||
<>
|
||||
{/* Mobile Card Layout */}
|
||||
<div className="md:hidden space-y-3 overflow-y-auto h-full pb-4">
|
||||
{filteredAndSortedRepositories.map((repo) => {
|
||||
const isSecure =
|
||||
repo.isSecure !== undefined
|
||||
? repo.isSecure
|
||||
: repo.url.startsWith("https://");
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Complex card layout requires div
|
||||
<div
|
||||
key={repo.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleRowClick(repo)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleRowClick(repo);
|
||||
}
|
||||
}}
|
||||
className="card p-4 space-y-3 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow w-full"
|
||||
>
|
||||
{/* Header with name and status */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Database className="h-5 w-5 text-secondary-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-secondary-900 dark:text-white truncate">
|
||||
{repo.name}
|
||||
</h3>
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "distribution",
|
||||
) && (
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-400 mt-0.5">
|
||||
{repo.distribution}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "status",
|
||||
) && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${
|
||||
repo.is_active
|
||||
? "bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300"
|
||||
: "bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{repo.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
{visibleColumns.some((col) => col.id === "url") && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mb-1">
|
||||
URL
|
||||
</p>
|
||||
<p
|
||||
className="text-sm text-secondary-900 dark:text-white font-mono truncate"
|
||||
title={repo.url}
|
||||
>
|
||||
{repo.url}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security and Hosts */}
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2 border-t border-secondary-200 dark:border-secondary-600">
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "security",
|
||||
) && (
|
||||
<div className="flex items-center gap-1">
|
||||
{isSecure ? (
|
||||
<>
|
||||
<Lock className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm text-green-600 font-medium">
|
||||
Secure
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Unlock className="h-4 w-4 text-orange-600" />
|
||||
<span className="text-sm text-orange-600 font-medium">
|
||||
Insecure
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{visibleColumns.some(
|
||||
(col) => col.id === "hostCount",
|
||||
) && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Server className="h-4 w-4 text-secondary-400" />
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
{repo.hostCount} Host
|
||||
{repo.hostCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{visibleColumns.some((col) => col.id === "actions") && (
|
||||
<div className="flex items-center justify-end pt-2 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteRepository(repo, e);
|
||||
}}
|
||||
className="text-orange-600 hover:text-red-900 dark:text-orange-600 dark:hover:text-red-400 flex items-center gap-1"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
title="Delete repository"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="text-sm">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop Table Layout */}
|
||||
<div className="hidden md:block h-full overflow-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
|
||||
<tr>
|
||||
{visibleColumns.map((column) => (
|
||||
<td
|
||||
<th
|
||||
key={column.id}
|
||||
className="px-4 py-2 whitespace-nowrap text-left"
|
||||
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
|
||||
>
|
||||
{renderCellContent(column, repo)}
|
||||
</td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort(column.id)}
|
||||
className="flex items-center justify-start gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
|
||||
>
|
||||
{column.label}
|
||||
{getSortIcon(column.id)}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredAndSortedRepositories.map((repo) => (
|
||||
<tr
|
||||
key={repo.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors cursor-pointer"
|
||||
onClick={() => handleRowClick(repo)}
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<td
|
||||
key={column.id}
|
||||
className="px-4 py-2 whitespace-nowrap text-left"
|
||||
>
|
||||
{renderCellContent(column, repo)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -728,14 +859,16 @@ const ColumnSettingsModal = ({
|
||||
|
||||
<div className="space-y-3">
|
||||
{columnConfig.map((column, index) => (
|
||||
<button
|
||||
type="button"
|
||||
// biome-ignore lint/a11y/useSemanticElements: Draggable element requires div
|
||||
<div
|
||||
key={column.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
className="flex items-center justify-between p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg cursor-move hover:bg-secondary-100 dark:hover:bg-secondary-600 transition-colors w-full text-left"
|
||||
className="flex items-center justify-between p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg cursor-move hover:bg-secondary-100 dark:hover:bg-secondary-600 transition-colors w-full"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="h-4 w-4 text-secondary-400" />
|
||||
@@ -745,7 +878,10 @@ const ColumnSettingsModal = ({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleVisibility(column.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVisibility(column.id);
|
||||
}}
|
||||
className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
|
||||
column.visible
|
||||
? "bg-primary-600 border-primary-600"
|
||||
@@ -754,7 +890,7 @@ const ColumnSettingsModal = ({
|
||||
>
|
||||
{column.visible && <Check className="h-3 w-3 text-white" />}
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ const Settings = () => {
|
||||
// Fallback clipboard copy function for HTTP and older browsers
|
||||
const copyToClipboard = async (text) => {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
|
||||
@@ -333,69 +333,118 @@ const Integrations = () => {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Integrations
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<p className="mt-1 text-xs md:text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Manage auto-enrollment tokens for Proxmox and other integrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg overflow-hidden">
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600 flex">
|
||||
{/* Mobile Button Navigation */}
|
||||
<div className="md:hidden space-y-2 p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("auto-enrollment")}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-md font-medium text-sm transition-colors ${
|
||||
activeTab === "auto-enrollment"
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
||||
? "bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400 border border-primary-200 dark:border-primary-800"
|
||||
: "bg-secondary-50 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 border border-secondary-200 dark:border-secondary-600 hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
}`}
|
||||
>
|
||||
Auto-Enrollment & API
|
||||
<span>Auto-Enrollment & API</span>
|
||||
{activeTab === "auto-enrollment" && (
|
||||
<CheckCircle className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("gethomepage")}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-md font-medium text-sm transition-colors ${
|
||||
activeTab === "gethomepage"
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
||||
? "bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400 border border-primary-200 dark:border-primary-800"
|
||||
: "bg-secondary-50 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 border border-secondary-200 dark:border-secondary-600 hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
}`}
|
||||
>
|
||||
GetHomepage
|
||||
<span>GetHomepage</span>
|
||||
{activeTab === "gethomepage" && (
|
||||
<CheckCircle className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("docker")}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-md font-medium text-sm transition-colors ${
|
||||
activeTab === "docker"
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
||||
? "bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400 border border-primary-200 dark:border-primary-800"
|
||||
: "bg-secondary-50 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 border border-secondary-200 dark:border-secondary-600 hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
}`}
|
||||
>
|
||||
Docker
|
||||
<span>Docker</span>
|
||||
{activeTab === "docker" && (
|
||||
<CheckCircle className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
)}
|
||||
</button>
|
||||
{/* Future tabs can be added here */}
|
||||
</div>
|
||||
|
||||
{/* Desktop Tab Navigation */}
|
||||
<div className="hidden md:block border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("auto-enrollment")}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === "auto-enrollment"
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
||||
}`}
|
||||
>
|
||||
Auto-Enrollment & API
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("gethomepage")}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === "gethomepage"
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
||||
}`}
|
||||
>
|
||||
GetHomepage
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("docker")}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === "docker"
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
||||
}`}
|
||||
>
|
||||
Docker
|
||||
</button>
|
||||
{/* Future tabs can be added here */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
<div className="p-4 md:p-6">
|
||||
{/* Auto-Enrollment & API Tab */}
|
||||
{activeTab === "auto-enrollment" && (
|
||||
<div className="space-y-6">
|
||||
{/* Header with New Token Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Server className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base md:text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Auto-Enrollment & API Credentials
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<p className="text-xs md:text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Manage tokens for Proxmox LXC auto-enrollment and API
|
||||
access
|
||||
</p>
|
||||
@@ -404,7 +453,7 @@ const Integrations = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center sm:justify-start"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Token
|
||||
@@ -549,12 +598,12 @@ const Integrations = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap w-full sm:w-auto">
|
||||
{token.metadata?.integration_type === "api" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open_edit_modal(token)}
|
||||
className="px-3 py-1 text-sm rounded bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300"
|
||||
className="px-3 py-1 text-xs md:text-sm rounded bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -564,7 +613,7 @@ const Integrations = () => {
|
||||
onClick={() =>
|
||||
toggle_token_active(token.id, token.is_active)
|
||||
}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
className={`px-3 py-1 text-xs md:text-sm rounded ${
|
||||
token.is_active
|
||||
? "bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-700 dark:text-secondary-300"
|
||||
: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900 dark:text-green-300"
|
||||
@@ -589,8 +638,8 @@ const Integrations = () => {
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-4">
|
||||
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-4 md:p-6">
|
||||
<h3 className="text-base md:text-lg font-semibold text-primary-900 dark:text-primary-200 mb-4">
|
||||
Documentation
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -648,16 +697,16 @@ const Integrations = () => {
|
||||
{activeTab === "gethomepage" && (
|
||||
<div className="space-y-6">
|
||||
{/* Header with New API Key Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Server className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base md:text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
GetHomepage Widget Integration
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<p className="text-xs md:text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Create API keys to display PatchMon statistics in your
|
||||
GetHomepage dashboard
|
||||
</p>
|
||||
@@ -666,7 +715,7 @@ const Integrations = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center sm:justify-start"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New API Key
|
||||
@@ -699,12 +748,12 @@ const Integrations = () => {
|
||||
.map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 hover:border-primary-300 dark:hover:border-primary-700 transition-colors"
|
||||
className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-3 md:p-4 hover:border-primary-300 dark:hover:border-primary-700 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-medium text-secondary-900 dark:text-white">
|
||||
<h4 className="text-sm md:text-base font-medium text-secondary-900 dark:text-white truncate">
|
||||
{token.token_name}
|
||||
</h4>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
@@ -720,9 +769,9 @@ const Integrations = () => {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<div className="mt-2 space-y-1 text-xs md:text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-1 rounded">
|
||||
<span className="font-mono text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-1 rounded break-all flex-1 min-w-0">
|
||||
{token.token_key}
|
||||
</span>
|
||||
<button
|
||||
@@ -733,7 +782,7 @@ const Integrations = () => {
|
||||
`key-${token.id}`,
|
||||
)
|
||||
}
|
||||
className="text-primary-600 hover:text-primary-700 dark:text-primary-400"
|
||||
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 flex-shrink-0"
|
||||
>
|
||||
{copy_success[`key-${token.id}`] ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
@@ -761,13 +810,13 @@ const Integrations = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap w-full sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggle_token_active(token.id, token.is_active)
|
||||
}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
className={`px-3 py-1 text-xs md:text-sm rounded ${
|
||||
token.is_active
|
||||
? "bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-700 dark:text-secondary-300"
|
||||
: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900 dark:text-green-300"
|
||||
@@ -792,16 +841,16 @@ const Integrations = () => {
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200">
|
||||
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-4 md:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-4">
|
||||
<h3 className="text-base md:text-lg font-semibold text-primary-900 dark:text-primary-200">
|
||||
How to Use GetHomepage Integration
|
||||
</h3>
|
||||
<a
|
||||
href="https://docs.patchmon.net/books/patchmon-application-documentation/page/gethomepagedev-dashboard-card"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 text-white rounded-lg flex items-center gap-2 transition-colors"
|
||||
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 text-white rounded-lg flex items-center gap-2 transition-colors w-full sm:w-auto justify-center sm:justify-start"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Documentation
|
||||
@@ -909,33 +958,33 @@ const Integrations = () => {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Container className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base md:text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Docker Inventory Collection
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<p className="text-xs md:text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Docker monitoring is now built into the PatchMon Go agent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Message */}
|
||||
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
|
||||
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-4 md:p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-md font-semibold text-primary-900 dark:text-primary-200 mb-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm md:text-base font-semibold text-primary-900 dark:text-primary-200 mb-2">
|
||||
Automatic Docker Discovery
|
||||
</h4>
|
||||
<p className="text-sm text-primary-800 dark:text-primary-300 mb-3">
|
||||
<p className="text-xs md:text-sm text-primary-800 dark:text-primary-300 mb-3">
|
||||
The PatchMon Go agent automatically discovers Docker
|
||||
when it's available on your host and collects
|
||||
comprehensive inventory information:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300 ml-2">
|
||||
<ul className="list-disc list-inside space-y-2 text-xs md:text-sm text-primary-800 dark:text-primary-300 ml-2">
|
||||
<li>
|
||||
<strong>Containers</strong> - Running and stopped
|
||||
containers with status, images, ports, and labels
|
||||
@@ -962,11 +1011,11 @@ const Integrations = () => {
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 md:p-6">
|
||||
<h4 className="text-sm md:text-base font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
How It Works
|
||||
</h4>
|
||||
<ol className="list-decimal list-inside space-y-3 text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<ol className="list-decimal list-inside space-y-3 text-xs md:text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<li>
|
||||
Install the PatchMon Go agent on your host (see the Hosts
|
||||
page for installation instructions)
|
||||
@@ -997,10 +1046,10 @@ const Integrations = () => {
|
||||
</div>
|
||||
|
||||
{/* No Configuration Required */}
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 md:p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-green-800 dark:text-green-200">
|
||||
<div className="text-xs md:text-sm text-green-800 dark:text-green-200">
|
||||
<p className="font-semibold mb-1">
|
||||
No Additional Configuration Required
|
||||
</p>
|
||||
@@ -1015,10 +1064,10 @@ const Integrations = () => {
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 md:p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<div className="text-xs md:text-sm text-blue-800 dark:text-blue-200">
|
||||
<p className="font-semibold mb-2">Requirements:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>PatchMon Go agent must be installed and running</li>
|
||||
@@ -1048,9 +1097,9 @@ const Integrations = () => {
|
||||
{show_create_modal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
<h2 className="text-lg md:text-xl font-bold text-secondary-900 dark:text-white">
|
||||
{activeTab === "gethomepage"
|
||||
? "Create GetHomepage API Key"
|
||||
: "Create Token"}
|
||||
@@ -1064,17 +1113,17 @@ const Integrations = () => {
|
||||
}}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
<X className="h-5 w-5 md:h-6 md:w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs for Auto-enrollment modal */}
|
||||
{activeTab === "auto-enrollment" && (
|
||||
<div className="flex border-b border-secondary-200 dark:border-secondary-700 mb-6">
|
||||
<div className="flex border-b border-secondary-200 dark:border-secondary-700 mb-4 md:mb-6 overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUsageType("proxmox-lxc")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`px-3 md:px-4 py-2 text-xs md:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
usage_type === "proxmox-lxc"
|
||||
? "text-primary-600 dark:text-primary-400 border-primary-500"
|
||||
: "text-secondary-500 dark:text-secondary-400 border-transparent hover:text-secondary-700 dark:hover:text-secondary-300"
|
||||
@@ -1085,7 +1134,7 @@ const Integrations = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUsageType("api")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`px-3 md:px-4 py-2 text-xs md:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
usage_type === "api"
|
||||
? "text-primary-600 dark:text-primary-400 border-primary-500"
|
||||
: "text-secondary-500 dark:text-secondary-400 border-transparent hover:text-secondary-700 dark:hover:text-secondary-300"
|
||||
@@ -1259,10 +1308,10 @@ const Integrations = () => {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 btn-primary py-2 px-4 rounded-md"
|
||||
className="flex-1 btn-primary py-2 px-4 rounded-md w-full sm:w-auto"
|
||||
>
|
||||
Create Token
|
||||
</button>
|
||||
@@ -1272,7 +1321,7 @@ const Integrations = () => {
|
||||
setShowCreateModal(false);
|
||||
setUsageType("proxmox-lxc");
|
||||
}}
|
||||
className="flex-1 bg-secondary-100 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 py-2 px-4 rounded-md hover:bg-secondary-200 dark:hover:bg-secondary-600"
|
||||
className="flex-1 bg-secondary-100 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 py-2 px-4 rounded-md hover:bg-secondary-200 dark:hover:bg-secondary-600 w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -1287,11 +1336,11 @@ const Integrations = () => {
|
||||
{new_token && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
<h2 className="text-lg font-bold text-secondary-900 dark:text-white">
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-between mb-4 gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CheckCircle className="h-5 w-5 md:h-6 md:w-6 text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||
<h2 className="text-base md:text-lg font-bold text-secondary-900 dark:text-white truncate">
|
||||
{new_token.metadata?.integration_type === "gethomepage" ||
|
||||
activeTab === "gethomepage"
|
||||
? "API Key Created Successfully"
|
||||
@@ -1309,7 +1358,7 @@ const Integrations = () => {
|
||||
setUsageType("proxmox-lxc");
|
||||
setSelectedScriptType("proxmox-lxc");
|
||||
}}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200"
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200 flex-shrink-0"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -1356,14 +1405,14 @@ const Integrations = () => {
|
||||
type="text"
|
||||
value={new_token.token_key}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono"
|
||||
className="flex-1 px-3 py-2 text-xs md:text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono break-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(new_token.token_key, "new-key")
|
||||
}
|
||||
className="btn-primary p-2"
|
||||
className="btn-primary p-2 flex-shrink-0"
|
||||
title="Copy Key"
|
||||
>
|
||||
{copy_success["new-key"] ? (
|
||||
@@ -1388,12 +1437,12 @@ const Integrations = () => {
|
||||
type={show_secret ? "text" : "password"}
|
||||
value={new_token.token_secret}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono"
|
||||
className="flex-1 px-3 py-2 text-xs md:text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono break-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecret(!show_secret)}
|
||||
className="p-2 text-secondary-600 hover:text-secondary-800 dark:text-secondary-400"
|
||||
className="p-2 text-secondary-600 hover:text-secondary-800 dark:text-secondary-400 flex-shrink-0"
|
||||
title="Toggle visibility"
|
||||
>
|
||||
{show_secret ? (
|
||||
@@ -1410,7 +1459,7 @@ const Integrations = () => {
|
||||
"new-secret",
|
||||
)
|
||||
}
|
||||
className="btn-primary p-2"
|
||||
className="btn-primary p-2 flex-shrink-0"
|
||||
title="Copy Secret"
|
||||
>
|
||||
{copy_success["new-secret"] ? (
|
||||
@@ -1460,12 +1509,12 @@ const Integrations = () => {
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-2">
|
||||
Basic cURL request:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`curl -u "${new_token.token_key}:${new_token.token_secret}" ${server_url}/api/v1/api/hosts`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-xs border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono"
|
||||
className="flex-1 px-3 py-2 text-xs border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono break-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1475,7 +1524,7 @@ const Integrations = () => {
|
||||
"api-curl-basic",
|
||||
)
|
||||
}
|
||||
className="btn-primary p-2"
|
||||
className="btn-primary p-2 flex-shrink-0"
|
||||
title="Copy cURL command"
|
||||
>
|
||||
{copy_success["api-curl-basic"] ? (
|
||||
@@ -1490,12 +1539,12 @@ const Integrations = () => {
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-2">
|
||||
Filter by host group:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`curl -u "${new_token.token_key}:${new_token.token_secret}" "${server_url}/api/v1/api/hosts?hostgroup=Production"`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-xs border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono"
|
||||
className="flex-1 px-3 py-2 text-xs border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono break-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1505,7 +1554,7 @@ const Integrations = () => {
|
||||
"api-curl-filter",
|
||||
)
|
||||
}
|
||||
className="btn-primary p-2"
|
||||
className="btn-primary p-2 flex-shrink-0"
|
||||
title="Copy cURL command"
|
||||
>
|
||||
{copy_success["api-curl-filter"] ? (
|
||||
@@ -1583,12 +1632,12 @@ const Integrations = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`curl ${curl_flags} "${getEnrollmentUrl()}" | ${selected_script_type === "proxmox-lxc" ? "bash" : "sh"}`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs break-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1598,7 +1647,7 @@ const Integrations = () => {
|
||||
"enrollment-command",
|
||||
)
|
||||
}
|
||||
className="btn-primary flex items-center gap-1 px-3 py-2 whitespace-nowrap"
|
||||
className="btn-primary flex items-center justify-center gap-1 px-3 py-2 whitespace-nowrap"
|
||||
>
|
||||
{copy_success["enrollment-command"] ? (
|
||||
<>
|
||||
@@ -1636,7 +1685,7 @@ const Integrations = () => {
|
||||
>
|
||||
Base64 Encoded Credentials
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<input
|
||||
id={token_base64_id}
|
||||
type="text"
|
||||
@@ -1644,7 +1693,7 @@ const Integrations = () => {
|
||||
`${new_token.token_key}:${new_token.token_secret}`,
|
||||
)}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono"
|
||||
className="flex-1 px-3 py-2 text-xs md:text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono break-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1656,7 +1705,7 @@ const Integrations = () => {
|
||||
"base64-creds",
|
||||
)
|
||||
}
|
||||
className="btn-primary p-2"
|
||||
className="btn-primary p-2 flex-shrink-0"
|
||||
title="Copy Base64"
|
||||
>
|
||||
{copy_success["base64-creds"] ? (
|
||||
@@ -1762,7 +1811,7 @@ const Integrations = () => {
|
||||
setUsageType("proxmox-lxc");
|
||||
setSelectedScriptType("proxmox-lxc");
|
||||
}}
|
||||
className="w-full btn-primary py-2 px-4 rounded-md"
|
||||
className="w-full btn-primary py-2 px-4 rounded-md text-sm md:text-base"
|
||||
>
|
||||
I've Saved the Credentials
|
||||
</button>
|
||||
@@ -1776,9 +1825,9 @@ const Integrations = () => {
|
||||
{show_edit_modal && edit_token && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-between mb-4 md:mb-6 gap-3">
|
||||
<h2 className="text-lg md:text-xl font-bold text-secondary-900 dark:text-white">
|
||||
Edit API Credential
|
||||
</h2>
|
||||
<button
|
||||
@@ -1787,9 +1836,9 @@ const Integrations = () => {
|
||||
setShowEditModal(false);
|
||||
setEditToken(null);
|
||||
}}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200"
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200 flex-shrink-0"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
<X className="h-5 w-5 md:h-6 md:w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1896,10 +1945,10 @@ const Integrations = () => {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 btn-primary py-2 px-4 rounded-md"
|
||||
className="flex-1 btn-primary py-2 px-4 rounded-md w-full sm:w-auto"
|
||||
>
|
||||
Update Credential
|
||||
</button>
|
||||
@@ -1909,7 +1958,7 @@ const Integrations = () => {
|
||||
setShowEditModal(false);
|
||||
setEditToken(null);
|
||||
}}
|
||||
className="flex-1 bg-secondary-100 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 py-2 px-4 rounded-md hover:bg-secondary-200 dark:hover:bg-secondary-600"
|
||||
className="flex-1 bg-secondary-100 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 py-2 px-4 rounded-md hover:bg-secondary-200 dark:hover:bg-secondary-600 w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Code, Settings } from "lucide-react";
|
||||
import { CheckCircle, Code, Settings } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import SettingsLayout from "../../components/SettingsLayout";
|
||||
@@ -52,8 +52,38 @@ const SettingsAgentConfig = () => {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600">
|
||||
{/* Mobile Button Navigation */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
navigate(tab.href);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-md font-medium text-sm transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400 border border-primary-200 dark:border-primary-800"
|
||||
: "bg-secondary-50 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 border border-secondary-200 dark:border-secondary-600 hover:bg-secondary-100 dark:hover:bg-secondary-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{tab.name}</span>
|
||||
</div>
|
||||
{activeTab === tab.id && (
|
||||
<CheckCircle className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop Tab Navigation */}
|
||||
<div className="hidden md:block border-b border-secondary-200 dark:border-secondary-600">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
|
||||
@@ -112,7 +112,7 @@ const SettingsHostGroups = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center sm:justify-end"
|
||||
title="Create host group"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -122,131 +122,186 @@ const SettingsHostGroups = () => {
|
||||
|
||||
{/* Host Groups Table */}
|
||||
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Group
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Color
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Hosts
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan="5" className="px-6 py-12 text-center">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td colSpan="5" className="px-6 py-12 text-center">
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
Error loading host groups
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{error.message || "Failed to load host groups"}
|
||||
</p>
|
||||
</div>
|
||||
{hostGroups && hostGroups.length > 0 ? (
|
||||
<>
|
||||
{/* Mobile Card Layout */}
|
||||
<div className="md:hidden space-y-3 p-4">
|
||||
{hostGroups.map((group) => (
|
||||
<div key={group.id} className="card p-4 space-y-3">
|
||||
{/* Group Name and Color */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold text-secondary-900 dark:text-white truncate">
|
||||
{group.name}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : hostGroups && hostGroups.length > 0 ? (
|
||||
hostGroups.map((group) => (
|
||||
<tr
|
||||
key={group.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-3"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{group.name}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{group.description || (
|
||||
<span className="text-secondary-400 italic">
|
||||
No description
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-secondary-300"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{group.color}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleHostsClick(group.id)}
|
||||
className="flex items-center text-sm text-secondary-500 dark:text-secondary-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
title={`View hosts in ${group.name}`}
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
{group._count?.hosts || 0} host
|
||||
{group._count?.hosts !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(group)}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
title="Edit group"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(group)}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 flex-shrink-0"
|
||||
title="Edit group"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{group.description && (
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{group.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color and Hosts */}
|
||||
<div className="flex items-center justify-between gap-3 pt-2 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-secondary-300 dark:border-secondary-600 flex-shrink-0"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 font-mono">
|
||||
{group.color}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleHostsClick(group.id)}
|
||||
className="flex items-center text-sm text-secondary-500 dark:text-secondary-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
title={`View hosts in ${group.name}`}
|
||||
>
|
||||
<Server className="h-4 w-4 mr-1" />
|
||||
{group._count?.hosts || 0} host
|
||||
{group._count?.hosts !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop Table Layout */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Group
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Color
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Hosts
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="5" className="px-6 py-12 text-center">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
No host groups found
|
||||
</p>
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
Click "Create Group" to create the first host group
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{hostGroups.map((group) => (
|
||||
<tr
|
||||
key={group.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-3"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{group.name}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{group.description || (
|
||||
<span className="text-secondary-400 italic">
|
||||
No description
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-secondary-300"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{group.color}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleHostsClick(group.id)}
|
||||
className="flex items-center text-sm text-secondary-500 dark:text-secondary-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
title={`View hosts in ${group.name}`}
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
{group._count?.hosts || 0} host
|
||||
{group._count?.hosts !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(group)}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
title="Edit group"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : isLoading ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6">
|
||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400 dark:text-danger-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800 dark:text-danger-200">
|
||||
Error loading host groups
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300 mt-1">
|
||||
{error.message || "Failed to load host groups"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-12 text-center">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
No host groups found
|
||||
</p>
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
Click "Create Group" to create the first host group
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "patchmon",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "patchmon",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.6",
|
||||
"license": "AGPL-3.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"backend": {
|
||||
"name": "patchmon-backend",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.6",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.13.1",
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "patchmon-frontend",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.6",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
Reference in New Issue
Block a user