mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
feat(auto-update): add auto-update policies and fix image update detection (#297)
* feat(auto-update): add auto-update policies and fix image update detection Auto-Update Policies (Skipper+ tier): - New scheduled task action type 'update' for check-then-update flow - Dedicated AutoUpdatePoliciesView with CRUD, cron presets, and run history - Conditional tier gating: Skipper gets auto-update, Admiral gets full scheduled ops - Backend executeUpdate: checks digests, pulls only if newer, atomic redeploy Image Update Detection fixes (all tiers): - Fix stack name key mismatch: use working_dir label instead of project label - Add 5-minute periodic frontend polling for background check results - Replace fixed 3s timeout with polling-based manual refresh via /api/image-updates/status - Clear update status after successful stack update * fix(ui): remove Skipper tier badge from Auto-Update Policies header * fix(ui): remove auto-update action from Scheduled Operations view Admiral users have a dedicated Auto-Update view — showing update tasks in Scheduled Operations too was confusing duplication. Each view now owns a distinct, non-overlapping set of action types. * fix(auto-update): fix node-stack linking and add All Stacks option - Stack dropdown now re-fetches when node selection changes using fetchForNode, and resets the selected stack - Node selector moved above stack selector with stack disabled until a node is picked - Added "All Stacks" wildcard option that checks and updates every stack on the selected node - Backend executeUpdate refactored to iterate over all stacks when target_id is "*", with per-stack error isolation * refactor(ui): replace Select dropdowns with searchable Combobox component Add a reusable Combobox component with inline search and use it for Node/Stack selectors in both Auto-Update Policies and Scheduled Operations dialogs. Also fixes node-stack linking bug where changing node didn't update the stack list. * fix(ui): resolve CI TypeScript errors in Combobox and ScheduledOperationsView Add missing searchPlaceholder prop to ComboboxProps interface and remove dead 'update' action filter that conflicted with the narrowed type union. * fix(ui): use Geist Sans font in toast component The toast renders via React portal on document.body, bypassing the app's font inheritance. Add explicit font-family declaration using var(--font-sans) to match Sencho's design system.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import * as React from "react"
|
||||
import { Check, ChevronsUpDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface ComboboxOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
options: ComboboxOption[]
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
emptyText?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = "Select...",
|
||||
searchPlaceholder,
|
||||
emptyText = "No results found.",
|
||||
disabled = false,
|
||||
className,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [search, setSearch] = React.useState("")
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null)
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const selectedLabel = options.find((o) => o.value === value)?.label
|
||||
|
||||
const filtered = search
|
||||
? options.filter((o) =>
|
||||
o.label.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: options
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
setSearch("")
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handler)
|
||||
return () => document.removeEventListener("mousedown", handler)
|
||||
}, [open])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
setSearch("")
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handler, true)
|
||||
return () => document.removeEventListener("keydown", handler, true)
|
||||
}, [open])
|
||||
|
||||
const handleSelect = (option: ComboboxOption) => {
|
||||
onValueChange(option.value === value ? "" : option.value)
|
||||
setOpen(false)
|
||||
setSearch("")
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={cn("relative w-full", className)}>
|
||||
{/* Trigger: static button when closed, inline search input when open */}
|
||||
{open ? (
|
||||
<div
|
||||
className="flex h-9 w-full items-center rounded-md border border-ring bg-transparent px-3 text-sm shadow-sm ring-1 ring-ring"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder ?? selectedLabel ?? placeholder}
|
||||
className="h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={false}
|
||||
disabled={disabled}
|
||||
onClick={() => { if (!disabled) setOpen(true) }}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="line-clamp-1">
|
||||
{selectedLabel ?? placeholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Options list — absolutely positioned overlay */}
|
||||
{open && (
|
||||
<div className="absolute left-0 top-[calc(100%+4px)] z-50 w-full rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] animate-in fade-in-0 zoom-in-95 slide-in-from-top-2">
|
||||
<div className="max-h-[200px] overflow-y-auto overflow-x-hidden p-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option)}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground",
|
||||
value === option.value && "bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{option.label}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 100 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="relative w-full max-w-sm rounded-xl p-4 backdrop-blur-xl bg-white/15 dark:bg-black/15 border border-gray-300/60 dark:border-gray-700/60 overflow-hidden ring-1 ring-gray-200/40 dark:ring-gray-700/40 drop-shadow-xl transition-all duration-300 ease-in-out transform hover:scale-105"
|
||||
className="relative w-full max-w-sm rounded-xl p-4 backdrop-blur-xl bg-white/15 dark:bg-black/15 border border-gray-300/60 dark:border-gray-700/60 overflow-hidden ring-1 ring-gray-200/40 dark:ring-gray-700/40 drop-shadow-xl transition-all duration-300 ease-in-out transform hover:scale-105 font-[family-name:var(--font-sans)]"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user