diff --git a/web/package-lock.json b/web/package-lock.json
index 7c3cf71..c13022c 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -22,9 +22,11 @@
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
+ "mode-watcher": "^1.1.0",
"openapi-typescript": "^7.13.0",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
+ "svelte-sonner": "^1.1.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
@@ -2084,6 +2086,73 @@
"node": ">=10"
}
},
+ "node_modules/mode-watcher": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
+ "integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "runed": "^0.25.0",
+ "svelte-toolbelt": "^0.7.1"
+ },
+ "peerDependencies": {
+ "svelte": "^5.27.0"
+ }
+ },
+ "node_modules/mode-watcher/node_modules/runed": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz",
+ "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/huntabyte",
+ "https://github.com/sponsors/tglide"
+ ],
+ "dependencies": {
+ "esm-env": "^1.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.7.0"
+ }
+ },
+ "node_modules/mode-watcher/node_modules/svelte-toolbelt": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
+ "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/huntabyte"
+ ],
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "runed": "^0.23.2",
+ "style-to-object": "^1.0.8"
+ },
+ "engines": {
+ "node": ">=18",
+ "pnpm": ">=8.7.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0"
+ }
+ },
+ "node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": {
+ "version": "0.23.4",
+ "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
+ "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/huntabyte",
+ "https://github.com/sponsors/tglide"
+ ],
+ "dependencies": {
+ "esm-env": "^1.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.7.0"
+ }
+ },
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -2450,6 +2519,36 @@
"typescript": ">=5.0.0"
}
},
+ "node_modules/svelte-sonner": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.0.tgz",
+ "integrity": "sha512-3lYM6ZIqWe+p9vwwWHGWP/ZdvHiUtzURsud2quIxivrX4rvpXh6i+geBGn0m3JS6KwW6W8VgbOl3xQMcDuh6gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "runed": "^0.28.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0"
+ }
+ },
+ "node_modules/svelte-sonner/node_modules/runed": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz",
+ "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/huntabyte",
+ "https://github.com/sponsors/tglide"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "esm-env": "^1.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.7.0"
+ }
+ },
"node_modules/svelte-toolbelt": {
"version": "0.10.6",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
diff --git a/web/package.json b/web/package.json
index 2276bda..bfb51a9 100644
--- a/web/package.json
+++ b/web/package.json
@@ -19,9 +19,11 @@
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
+ "mode-watcher": "^1.1.0",
"openapi-typescript": "^7.13.0",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
+ "svelte-sonner": "^1.1.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
diff --git a/web/src/lib/components/app-sidebar.svelte b/web/src/lib/components/app-sidebar.svelte
index 6dd63aa..822a04f 100644
--- a/web/src/lib/components/app-sidebar.svelte
+++ b/web/src/lib/components/app-sidebar.svelte
@@ -1,5 +1,5 @@
+
+{#if partial}
+ {#if partialSnippet}
+ {@render partialSnippet()}
+ {:else}
+
+ {partialTitle}
+ {partialDescription}
+
+ {/if}
+{/if}
+
+{#if err}
+
+ {hasStaleData ? 'Ошибка обновления' : 'Ошибка'}
+ {err}
+ {#if hasStaleData}
+ Показаны последние успешные данные.
+ {/if}
+
+{/if}
+
+{#if showSkeleton}
+ {@render skeleton?.()}
+{:else if err && !hasStaleData}
+
+{:else if isEmpty}
+ {@render empty?.()}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/web/src/lib/components/fleet/fleet-live-stale-badge.svelte b/web/src/lib/components/fleet/fleet-live-stale-badge.svelte
new file mode 100644
index 0000000..ab7a88b
--- /dev/null
+++ b/web/src/lib/components/fleet/fleet-live-stale-badge.svelte
@@ -0,0 +1,38 @@
+
+
+
+
+
+ {isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
+
+
+
+ {tip}
+
+
diff --git a/web/src/lib/components/fleet/fleet-refresh-input.svelte b/web/src/lib/components/fleet/fleet-refresh-input.svelte
new file mode 100644
index 0000000..0c1cd9c
--- /dev/null
+++ b/web/src/lib/components/fleet/fleet-refresh-input.svelte
@@ -0,0 +1,27 @@
+
+
+
+ Refresh (sec)
+
+
diff --git a/web/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/web/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
new file mode 100644
index 0000000..7e63004
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
@@ -0,0 +1,27 @@
+
+
+
diff --git a/web/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/web/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
new file mode 100644
index 0000000..a4ce03c
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
@@ -0,0 +1,27 @@
+
+
+
diff --git a/web/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/web/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
new file mode 100644
index 0000000..251e852
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
@@ -0,0 +1,32 @@
+
+
+
+
+
+
diff --git a/web/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/web/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
new file mode 100644
index 0000000..5024f3f
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/web/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/web/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
new file mode 100644
index 0000000..f4214db
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/web/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
new file mode 100644
index 0000000..d6df4d3
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/alert-dialog/alert-dialog-media.svelte b/web/src/lib/components/ui/alert-dialog/alert-dialog-media.svelte
new file mode 100644
index 0000000..3f634f3
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/alert-dialog-media.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/web/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
new file mode 100644
index 0000000..9ffcc85
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/web/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte b/web/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte
new file mode 100644
index 0000000..f0a19a8
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/web/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/web/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
new file mode 100644
index 0000000..652da33
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/web/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte b/web/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte
new file mode 100644
index 0000000..b22d1d5
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/web/src/lib/components/ui/alert-dialog/alert-dialog.svelte b/web/src/lib/components/ui/alert-dialog/alert-dialog.svelte
new file mode 100644
index 0000000..7ea78bb
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/alert-dialog.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/web/src/lib/components/ui/alert-dialog/index.ts b/web/src/lib/components/ui/alert-dialog/index.ts
new file mode 100644
index 0000000..ca81c2a
--- /dev/null
+++ b/web/src/lib/components/ui/alert-dialog/index.ts
@@ -0,0 +1,40 @@
+import Root from "./alert-dialog.svelte";
+import Portal from "./alert-dialog-portal.svelte";
+import Trigger from "./alert-dialog-trigger.svelte";
+import Title from "./alert-dialog-title.svelte";
+import Action from "./alert-dialog-action.svelte";
+import Cancel from "./alert-dialog-cancel.svelte";
+import Footer from "./alert-dialog-footer.svelte";
+import Header from "./alert-dialog-header.svelte";
+import Overlay from "./alert-dialog-overlay.svelte";
+import Content from "./alert-dialog-content.svelte";
+import Description from "./alert-dialog-description.svelte";
+import Media from "./alert-dialog-media.svelte";
+
+export {
+ Root,
+ Title,
+ Action,
+ Cancel,
+ Portal,
+ Footer,
+ Header,
+ Trigger,
+ Overlay,
+ Content,
+ Description,
+ Media,
+ //
+ Root as AlertDialog,
+ Title as AlertDialogTitle,
+ Action as AlertDialogAction,
+ Cancel as AlertDialogCancel,
+ Portal as AlertDialogPortal,
+ Footer as AlertDialogFooter,
+ Header as AlertDialogHeader,
+ Trigger as AlertDialogTrigger,
+ Overlay as AlertDialogOverlay,
+ Content as AlertDialogContent,
+ Description as AlertDialogDescription,
+ Media as AlertDialogMedia,
+};
diff --git a/web/src/lib/components/ui/command/command-dialog.svelte b/web/src/lib/components/ui/command/command-dialog.svelte
new file mode 100644
index 0000000..63b445c
--- /dev/null
+++ b/web/src/lib/components/ui/command/command-dialog.svelte
@@ -0,0 +1,42 @@
+
+
+
+
+ {title}
+ {description}
+
+
+
+
+
diff --git a/web/src/lib/components/ui/command/command-empty.svelte b/web/src/lib/components/ui/command/command-empty.svelte
new file mode 100644
index 0000000..6726cd8
--- /dev/null
+++ b/web/src/lib/components/ui/command/command-empty.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/web/src/lib/components/ui/command/command-group.svelte b/web/src/lib/components/ui/command/command-group.svelte
new file mode 100644
index 0000000..f5da0df
--- /dev/null
+++ b/web/src/lib/components/ui/command/command-group.svelte
@@ -0,0 +1,32 @@
+
+
+
+ {#if heading}
+
+ {heading}
+
+ {/if}
+
+
diff --git a/web/src/lib/components/ui/command/command-input.svelte b/web/src/lib/components/ui/command/command-input.svelte
new file mode 100644
index 0000000..039425f
--- /dev/null
+++ b/web/src/lib/components/ui/command/command-input.svelte
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/ui/command/command-item.svelte b/web/src/lib/components/ui/command/command-item.svelte
new file mode 100644
index 0000000..4246840
--- /dev/null
+++ b/web/src/lib/components/ui/command/command-item.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/web/src/lib/components/ui/command/command-link-item.svelte b/web/src/lib/components/ui/command/command-link-item.svelte
new file mode 100644
index 0000000..ada6d2c
--- /dev/null
+++ b/web/src/lib/components/ui/command/command-link-item.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/web/src/lib/components/ui/command/command-list.svelte b/web/src/lib/components/ui/command/command-list.svelte
new file mode 100644
index 0000000..f663bd3
--- /dev/null
+++ b/web/src/lib/components/ui/command/command-list.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/web/src/lib/components/ui/command/command-loading.svelte b/web/src/lib/components/ui/command/command-loading.svelte
new file mode 100644
index 0000000..19dd298
--- /dev/null
+++ b/web/src/lib/components/ui/command/command-loading.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/web/src/lib/components/ui/command/command-separator.svelte b/web/src/lib/components/ui/command/command-separator.svelte
new file mode 100644
index 0000000..35c4c95
--- /dev/null
+++ b/web/src/lib/components/ui/command/command-separator.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/web/src/lib/components/ui/command/command-shortcut.svelte b/web/src/lib/components/ui/command/command-shortcut.svelte
new file mode 100644
index 0000000..5322e6a
--- /dev/null
+++ b/web/src/lib/components/ui/command/command-shortcut.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/command/command.svelte b/web/src/lib/components/ui/command/command.svelte
new file mode 100644
index 0000000..c814599
--- /dev/null
+++ b/web/src/lib/components/ui/command/command.svelte
@@ -0,0 +1,25 @@
+
+
+
diff --git a/web/src/lib/components/ui/command/index.ts b/web/src/lib/components/ui/command/index.ts
new file mode 100644
index 0000000..5435fbe
--- /dev/null
+++ b/web/src/lib/components/ui/command/index.ts
@@ -0,0 +1,37 @@
+import Root from "./command.svelte";
+import Loading from "./command-loading.svelte";
+import Dialog from "./command-dialog.svelte";
+import Empty from "./command-empty.svelte";
+import Group from "./command-group.svelte";
+import Item from "./command-item.svelte";
+import Input from "./command-input.svelte";
+import List from "./command-list.svelte";
+import Separator from "./command-separator.svelte";
+import Shortcut from "./command-shortcut.svelte";
+import LinkItem from "./command-link-item.svelte";
+
+export {
+ Root,
+ Dialog,
+ Empty,
+ Group,
+ Item,
+ LinkItem,
+ Input,
+ List,
+ Separator,
+ Shortcut,
+ Loading,
+ //
+ Root as Command,
+ Dialog as CommandDialog,
+ Empty as CommandEmpty,
+ Group as CommandGroup,
+ Item as CommandItem,
+ LinkItem as CommandLinkItem,
+ Input as CommandInput,
+ List as CommandList,
+ Separator as CommandSeparator,
+ Shortcut as CommandShortcut,
+ Loading as CommandLoading,
+};
diff --git a/web/src/lib/components/ui/input-group/index.ts b/web/src/lib/components/ui/input-group/index.ts
new file mode 100644
index 0000000..fe1f55d
--- /dev/null
+++ b/web/src/lib/components/ui/input-group/index.ts
@@ -0,0 +1,22 @@
+import Root from "./input-group.svelte";
+import Addon from "./input-group-addon.svelte";
+import Button from "./input-group-button.svelte";
+import Input from "./input-group-input.svelte";
+import Text from "./input-group-text.svelte";
+import Textarea from "./input-group-textarea.svelte";
+
+export {
+ Root,
+ Addon,
+ Button,
+ Input,
+ Text,
+ Textarea,
+ //
+ Root as InputGroup,
+ Addon as InputGroupAddon,
+ Button as InputGroupButton,
+ Input as InputGroupInput,
+ Text as InputGroupText,
+ Textarea as InputGroupTextarea,
+};
diff --git a/web/src/lib/components/ui/input-group/input-group-addon.svelte b/web/src/lib/components/ui/input-group/input-group-addon.svelte
new file mode 100644
index 0000000..ea075ea
--- /dev/null
+++ b/web/src/lib/components/ui/input-group/input-group-addon.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+ {
+ if ((e.target as HTMLElement).closest("button")) {
+ return;
+ }
+ e.currentTarget.parentElement?.querySelector("input")?.focus();
+ }}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/input-group/input-group-button.svelte b/web/src/lib/components/ui/input-group/input-group-button.svelte
new file mode 100644
index 0000000..adb05b9
--- /dev/null
+++ b/web/src/lib/components/ui/input-group/input-group-button.svelte
@@ -0,0 +1,49 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/input-group/input-group-input.svelte b/web/src/lib/components/ui/input-group/input-group-input.svelte
new file mode 100644
index 0000000..316c0cb
--- /dev/null
+++ b/web/src/lib/components/ui/input-group/input-group-input.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/web/src/lib/components/ui/input-group/input-group-text.svelte b/web/src/lib/components/ui/input-group/input-group-text.svelte
new file mode 100644
index 0000000..77907c6
--- /dev/null
+++ b/web/src/lib/components/ui/input-group/input-group-text.svelte
@@ -0,0 +1,19 @@
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/input-group/input-group-textarea.svelte b/web/src/lib/components/ui/input-group/input-group-textarea.svelte
new file mode 100644
index 0000000..9fbabe6
--- /dev/null
+++ b/web/src/lib/components/ui/input-group/input-group-textarea.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/web/src/lib/components/ui/input-group/input-group.svelte b/web/src/lib/components/ui/input-group/input-group.svelte
new file mode 100644
index 0000000..42b91be
--- /dev/null
+++ b/web/src/lib/components/ui/input-group/input-group.svelte
@@ -0,0 +1,24 @@
+
+
+[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
+ className
+ )}
+ {...props}
+>
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/pagination/index.ts b/web/src/lib/components/ui/pagination/index.ts
new file mode 100644
index 0000000..214b851
--- /dev/null
+++ b/web/src/lib/components/ui/pagination/index.ts
@@ -0,0 +1,31 @@
+import Root from "./pagination.svelte";
+import Content from "./pagination-content.svelte";
+import Item from "./pagination-item.svelte";
+import Link from "./pagination-link.svelte";
+import PrevButton from "./pagination-prev-button.svelte";
+import NextButton from "./pagination-next-button.svelte";
+import Ellipsis from "./pagination-ellipsis.svelte";
+import Previous from "./pagination-previous.svelte";
+import Next from "./pagination-next.svelte";
+
+export {
+ Root,
+ Content,
+ Item,
+ Link,
+ PrevButton, // old
+ NextButton, // old
+ Ellipsis,
+ Previous,
+ Next,
+ //
+ Root as Pagination,
+ Content as PaginationContent,
+ Item as PaginationItem,
+ Link as PaginationLink,
+ PrevButton as PaginationPrevButton, // old
+ NextButton as PaginationNextButton, // old
+ Ellipsis as PaginationEllipsis,
+ Previous as PaginationPrevious,
+ Next as PaginationNext,
+};
diff --git a/web/src/lib/components/ui/pagination/pagination-content.svelte b/web/src/lib/components/ui/pagination/pagination-content.svelte
new file mode 100644
index 0000000..cdf7415
--- /dev/null
+++ b/web/src/lib/components/ui/pagination/pagination-content.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/pagination/pagination-ellipsis.svelte b/web/src/lib/components/ui/pagination/pagination-ellipsis.svelte
new file mode 100644
index 0000000..ca9b8c3
--- /dev/null
+++ b/web/src/lib/components/ui/pagination/pagination-ellipsis.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+ More pages
+
diff --git a/web/src/lib/components/ui/pagination/pagination-item.svelte b/web/src/lib/components/ui/pagination/pagination-item.svelte
new file mode 100644
index 0000000..fd7ffc3
--- /dev/null
+++ b/web/src/lib/components/ui/pagination/pagination-item.svelte
@@ -0,0 +1,14 @@
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/pagination/pagination-link.svelte b/web/src/lib/components/ui/pagination/pagination-link.svelte
new file mode 100644
index 0000000..d147bd2
--- /dev/null
+++ b/web/src/lib/components/ui/pagination/pagination-link.svelte
@@ -0,0 +1,42 @@
+
+
+{#snippet Fallback()}
+ {page.value}
+{/snippet}
+
+
+ {#if children}
+ {@render children?.()}
+ {:else}
+ {@render Fallback()}
+ {/if}
+
diff --git a/web/src/lib/components/ui/pagination/pagination-next-button.svelte b/web/src/lib/components/ui/pagination/pagination-next-button.svelte
new file mode 100644
index 0000000..4344024
--- /dev/null
+++ b/web/src/lib/components/ui/pagination/pagination-next-button.svelte
@@ -0,0 +1,31 @@
+
+
+{#snippet Fallback()}
+ Next
+
+{/snippet}
+
+
+ {#if children}
+ {@render children?.()}
+ {:else}
+ {@render Fallback()}
+ {/if}
+
diff --git a/web/src/lib/components/ui/pagination/pagination-next.svelte b/web/src/lib/components/ui/pagination/pagination-next.svelte
new file mode 100644
index 0000000..00c5189
--- /dev/null
+++ b/web/src/lib/components/ui/pagination/pagination-next.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/web/src/lib/components/ui/pagination/pagination-prev-button.svelte b/web/src/lib/components/ui/pagination/pagination-prev-button.svelte
new file mode 100644
index 0000000..106ccfa
--- /dev/null
+++ b/web/src/lib/components/ui/pagination/pagination-prev-button.svelte
@@ -0,0 +1,31 @@
+
+
+{#snippet Fallback()}
+
+ Previous
+{/snippet}
+
+
+ {#if children}
+ {@render children?.()}
+ {:else}
+ {@render Fallback()}
+ {/if}
+
diff --git a/web/src/lib/components/ui/pagination/pagination-previous.svelte b/web/src/lib/components/ui/pagination/pagination-previous.svelte
new file mode 100644
index 0000000..573e1b8
--- /dev/null
+++ b/web/src/lib/components/ui/pagination/pagination-previous.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/web/src/lib/components/ui/pagination/pagination.svelte b/web/src/lib/components/ui/pagination/pagination.svelte
new file mode 100644
index 0000000..8fdb5e8
--- /dev/null
+++ b/web/src/lib/components/ui/pagination/pagination.svelte
@@ -0,0 +1,28 @@
+
+
+
diff --git a/web/src/lib/components/ui/popover/index.ts b/web/src/lib/components/ui/popover/index.ts
new file mode 100644
index 0000000..5f18036
--- /dev/null
+++ b/web/src/lib/components/ui/popover/index.ts
@@ -0,0 +1,28 @@
+import Root from "./popover.svelte";
+import Close from "./popover-close.svelte";
+import Content from "./popover-content.svelte";
+import Description from "./popover-description.svelte";
+import Header from "./popover-header.svelte";
+import Title from "./popover-title.svelte";
+import Trigger from "./popover-trigger.svelte";
+import Portal from "./popover-portal.svelte";
+
+export {
+ Root,
+ Content,
+ Description,
+ Header,
+ Title,
+ Trigger,
+ Close,
+ Portal,
+ //
+ Root as Popover,
+ Content as PopoverContent,
+ Description as PopoverDescription,
+ Header as PopoverHeader,
+ Title as PopoverTitle,
+ Trigger as PopoverTrigger,
+ Close as PopoverClose,
+ Portal as PopoverPortal,
+};
diff --git a/web/src/lib/components/ui/popover/popover-close.svelte b/web/src/lib/components/ui/popover/popover-close.svelte
new file mode 100644
index 0000000..c360925
--- /dev/null
+++ b/web/src/lib/components/ui/popover/popover-close.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/web/src/lib/components/ui/popover/popover-content.svelte b/web/src/lib/components/ui/popover/popover-content.svelte
new file mode 100644
index 0000000..dd9a38f
--- /dev/null
+++ b/web/src/lib/components/ui/popover/popover-content.svelte
@@ -0,0 +1,31 @@
+
+
+
+
+
diff --git a/web/src/lib/components/ui/popover/popover-description.svelte b/web/src/lib/components/ui/popover/popover-description.svelte
new file mode 100644
index 0000000..c28ab23
--- /dev/null
+++ b/web/src/lib/components/ui/popover/popover-description.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/popover/popover-header.svelte b/web/src/lib/components/ui/popover/popover-header.svelte
new file mode 100644
index 0000000..69b85b1
--- /dev/null
+++ b/web/src/lib/components/ui/popover/popover-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/popover/popover-portal.svelte b/web/src/lib/components/ui/popover/popover-portal.svelte
new file mode 100644
index 0000000..dd8265f
--- /dev/null
+++ b/web/src/lib/components/ui/popover/popover-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/web/src/lib/components/ui/popover/popover-title.svelte b/web/src/lib/components/ui/popover/popover-title.svelte
new file mode 100644
index 0000000..ee9f2ff
--- /dev/null
+++ b/web/src/lib/components/ui/popover/popover-title.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/popover/popover-trigger.svelte b/web/src/lib/components/ui/popover/popover-trigger.svelte
new file mode 100644
index 0000000..586323c
--- /dev/null
+++ b/web/src/lib/components/ui/popover/popover-trigger.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/web/src/lib/components/ui/popover/popover.svelte b/web/src/lib/components/ui/popover/popover.svelte
new file mode 100644
index 0000000..6b1aa5f
--- /dev/null
+++ b/web/src/lib/components/ui/popover/popover.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/web/src/lib/components/ui/sonner/index.ts b/web/src/lib/components/ui/sonner/index.ts
new file mode 100644
index 0000000..1ad9f4a
--- /dev/null
+++ b/web/src/lib/components/ui/sonner/index.ts
@@ -0,0 +1 @@
+export { default as Toaster } from "./sonner.svelte";
diff --git a/web/src/lib/components/ui/sonner/sonner.svelte b/web/src/lib/components/ui/sonner/sonner.svelte
new file mode 100644
index 0000000..34b456d
--- /dev/null
+++ b/web/src/lib/components/ui/sonner/sonner.svelte
@@ -0,0 +1,34 @@
+
+
+
+ {#snippet loadingIcon()}
+
+ {/snippet}
+ {#snippet successIcon()}
+
+ {/snippet}
+ {#snippet errorIcon()}
+
+ {/snippet}
+ {#snippet infoIcon()}
+
+ {/snippet}
+ {#snippet warningIcon()}
+
+ {/snippet}
+
diff --git a/web/src/lib/components/ui/switch/index.ts b/web/src/lib/components/ui/switch/index.ts
new file mode 100644
index 0000000..f5533db
--- /dev/null
+++ b/web/src/lib/components/ui/switch/index.ts
@@ -0,0 +1,7 @@
+import Root from "./switch.svelte";
+
+export {
+ Root,
+ //
+ Root as Switch,
+};
diff --git a/web/src/lib/components/ui/switch/switch.svelte b/web/src/lib/components/ui/switch/switch.svelte
new file mode 100644
index 0000000..6aa57d5
--- /dev/null
+++ b/web/src/lib/components/ui/switch/switch.svelte
@@ -0,0 +1,31 @@
+
+
+
+
+
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 7b526ce..e532c58 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -1,18 +1,21 @@
@@ -220,9 +227,7 @@
{/if}
-
- {isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
-
+
void load()} disabled={loading}>
Обновить
@@ -232,32 +237,36 @@
-{#if partial}
-
- Частичные данные
- Не все upstream ответили успешно.
-
-{/if}
-
-{#if err}
-
- Ошибка
- {err}
-
-{:else}
+
+ {#snippet skeleton()}
+
+ {#each Array.from({ length: 3 }) as _, i (i)}
+
+
+
+
+
+
+ {/each}
+
+
+
+ {#each Array.from({ length: 6 }) as _, i (i)}
+
+ {/each}
+
+
+ {/snippet}
+ {#snippet children()}
@@ -348,9 +357,9 @@
-
@@ -360,9 +369,9 @@
/>
-
@@ -379,4 +388,5 @@
-{/if}
+ {/snippet}
+
diff --git a/web/src/routes/ips/+page.svelte b/web/src/routes/ips/+page.svelte
index 341744f..d29c1d4 100644
--- a/web/src/routes/ips/+page.svelte
+++ b/web/src/routes/ips/+page.svelte
@@ -9,11 +9,17 @@
import { flagEmoji } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
- import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
+ import { Badge } from '$lib/components/ui/badge/index.js';
import * as Table from '$lib/components/ui/table/index.js';
+ import { Skeleton } from '$lib/components/ui/skeleton/index.js';
+ import { Switch } from '$lib/components/ui/switch/index.js';
+ import { Label } from '$lib/components/ui/label/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
+ import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
+ import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
+ import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
let loading = $state(true);
let err = $state
(null);
@@ -410,6 +416,13 @@
if (staleTimer) clearInterval(staleTimer);
};
});
+
+ let totalIpRows = $derived(
+ rows.reduce((n, ur) => n + (ur.ips?.length ?? 0), 0)
+ );
+ let hasStaleData = $derived(err != null && rows.length > 0);
+ let showSkeleton = $derived(loading && rows.length === 0 && !err);
+ let isDataEmpty = $derived(!loading && totalIpRows === 0 && !err);
@@ -418,18 +431,15 @@
Снимок active/recent с шлюза; GeoIP из конфига шлюза.
-
- {isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
-
-
-
+
+ void applyControls()}
- class="rounded border"
+ onCheckedChange={() => void applyControls()}
/>
- GeoIP
-
+ GeoIP
+
void load()} disabled={loading}>
Обновить
@@ -439,34 +449,53 @@
-{#if partial}
-
- Частичные данные
- Не все upstream успешны.
-
-{/if}
-
-{#if err}
-
- Ошибка
- {err}
-
-{:else if loading && rows.length === 0}
- Загрузка…
-{:else}
+
+ {#snippet skeleton()}
+
+
+
+
+
+
+
+
+
+
+
+
+ {#each Array.from({ length: 6 }) as _, i (i)}
+
+ {/each}
+
+
+
+ {/snippet}
+ {#snippet empty()}
+
+
+ Нет данных
+
+ По текущим фильтрам нет IP в снимке. Смените alias или нажмите «Обновить».
+
+
+
+ void load()} disabled={loading}>
+
+ Обновить
+
+
+
+ {/snippet}
+ {#snippet children()}
{#if geo}
@@ -564,7 +593,8 @@
-{/if}
+ {/snippet}
+