Mobile Ui mainly

Fixes on Mobile responsiveness.
This commit is contained in:
9 Technology Group LTD
2025-12-14 17:26:25 +00:00
committed by GitHub
22 changed files with 4572 additions and 1971 deletions

58
.gitattributes vendored Normal file
View 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

View File

@@ -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"

View File

@@ -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

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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:&nbsp;
</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:&nbsp;
</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 */}

View File

@@ -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>
)}

View File

@@ -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

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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:&nbsp;
</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:&nbsp;
</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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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>

View File

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

View File

@@ -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
View File

@@ -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",