mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
release-2.5.0 (#994)
* feat(sshid) - sshid.io equivalent for termix (#919) * feat(ssh-id): database schema, migrations and field encryption Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys stored plaintext for the unauthenticated resolver; CA private key registered for per-user field encryption), with UNIQUE(user_id), an index on ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations. * feat(ssh-id): backend API — resolver, key management, CA and certificates Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver (+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store + noindex headers on every resolver response including early 404s. Authenticated management: claim/rename/delete handle, add/import/generate/enable/delete keys, and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate issuance. Audit logging on all mutations; UNIQUE races map to a precise 409. Unit tests for key parsing and certificate signing (ssh-keygen-validated). * feat(ssh-id): frontend panel, API client and i18n SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL + curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with server trust command, and per-key certificate issuance. API client re-exported through main-axios.ts; all strings i18n'd. * style(ssh-id): align panel and resolver page with Termix theme - Rebuild the SSH ID sidebar panel with the theme's square components (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards; use accent-brand and destructive tokens rather than raw red/green. - Fix panel scrolling: move overflow to a block scroll container so the cards keep their natural height instead of being clipped. - Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix dark theme: square corners, #18181b/#303032 palette, #f59145 accent, uppercase section labels. - Tidy copy: 'Save To Credentials' label, drop the redundant generate intro, and correct the generate tooltip (the key is stored when saving to vault). * feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923) Bumps the dev-patch-updates group with 11 updates: | Package | From | To | | --- | --- | --- | | [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` | | [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` | | [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` | | [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` | Updates `@codemirror/search` from 6.7.0 to 6.7.1 - [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/search/commits) Updates `@codemirror/view` from 6.43.0 to 6.43.1 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite) Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8) Updates `@vitest/ui` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui) Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3 - [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases) - [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md) - [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3) Updates `lint-staged` from 17.0.7 to 17.0.8 - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8) Updates `prettier` from 3.8.3 to 3.8.4 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4) Updates `sharp` from 0.35.1 to 0.35.2 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2) Updates `tailwindcss` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss) Updates `vitest` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: "@codemirror/search" dependency-version: 6.7.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: eslint-plugin-react-refresh dependency-version: 0.5.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: lint-staged dependency-version: 17.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: prettier dependency-version: 3.8.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump nanoid in the prod-patch-updates group (#925) Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid). Updates `nanoid` from 5.1.11 to 5.1.15 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15) --- updated-dependencies: - dependency-name: nanoid dependency-version: 5.1.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the major-updates group with 5 updates (#926) Bumps the major-updates group with 5 updates: | Package | From | To | | --- | --- | --- | | [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` | | [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` | | [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` | Updates `js-yaml` from 4.2.0 to 5.0.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0) Updates `@eslint/js` from 9.39.4 to 10.0.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js) Updates `@types/node` from 25.9.2 to 26.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `concurrently` from 9.2.1 to 10.0.3 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3) Updates `eslint` from 9.39.4 to 10.5.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@eslint/js" dependency-version: 10.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@types/node" dependency-version: 26.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: concurrently dependency-version: 10.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: eslint dependency-version: 10.5.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(ssh): add HashiCorp Vault SSH signer authentication * fix: small fixes to vault feature to align with Termix codebase * chore: add view docs links for vault/termix id * fix: file upload fails with 400 and missing schema migrations on upgrade (#929) Two bugs introduced in v2.4.1: 1. uploadFileStream uses fileManagerApi.post() which triggers axios's transformRequest to JSON-serialize the FormData because the instance default Content-Type is application/json. Change to postForm() which sets Content-Type: multipart/form-data so the browser XHR sends the correct multipart body with boundary. 2. Two schema items added to schema.ts were not included in migrateSchema() in db/index.ts, causing 500 errors on existing installations upgrading from v2.4.0: - user_preferences.status_color_scheme (no such column) - dashboard_service_links table (no such table) Fixes #928 Co-authored-by: sash <sash@fominykh.io> * fix: support PuTTY PPK ssh keys (#930) * fix: chunk large file manager uploads (#932) * fix: route dashboard hosts by protocol (#934) * fix: resolve tunnel endpoints reliably (#935) * Fix Electron OIDC browser auth failures (#936) * Allow RDP connections without stored credentials (#937) * Sync role credential shares for OIDC users (#938) * Fix terminal link dialog layering (#940) * Confirm large files before opening editor (#942) * Confirm closing active host connections (#943) * Preserve file path case in file manager UI (#941) * fix: preserve unicode guacamole tokens (#933) * Persist VNC authentication settings (#944) * Fix Guacamole websocket base path (#946) * Promote file manager terminals to tabs (#939) * Guard Guacamole disconnect during startup (#945) * chore: increment ver * feat: bitwarden ssh agent integration * feat: serial connections support * fix: various small bug fixes * feat: open all sessions in a folder and terminal custom theme color support * feat: cross host file manager clipboard and several small bug fixes * feat: tailscale/wireguard support and added a new status state for when backend is checking status * feat: grafana like server stats history, new alert system, ntfy/webhook support * feat: new grid and widget based homepage function * feat: new donate button in dashboard * fix: alert ui incorrectly using termix css and fixed issue with alert system not loading * chore: fix ci checks (#966) * Fix dashboard service link creation (#950) * Fix jump host SOCKS5 proxy selection (#951) * Fix jump host SOCKS5 proxy selection * fix: type jump host socks proxy config * Fix tmux detection path handling (#949) * Support GUACD_URL environment config (#952) * Retry autostart tunnel host fetches (#953) * Retry autostart tunnel host fetches * chore: format tunnel route * Fix PUID html ownership in Docker entrypoint (#954) * feat: allow custom tunnel endpoints (#977) * fix: skip metrics start for non-ssh hosts (#976) * Fix Proxmox import auth fallback (#956) * Fix Proxmox import auth fallback * chore: format proxmox import auth * Fix SSH heading syntax highlighting (#955) * Fix SSH heading syntax highlighting * chore: format terminal highlighter * Initialize auth before fullscreen terminal routes (#957) * Add WebAuthn passkey authentication (#959) * chore: add Biome tooling (#965) * chore: add biome tooling * chore: support tailwind syntax in biome * Fix VNC required argument handshake (#968) * Prioritize host results in command palette search (#969) * Prevent sidebar host hover layout shift (#970) * Add terminal font zoom with mouse wheel (#971) * Add host temperature metrics card (#972) * Make file downloads reliable in desktop app (#973) * Add app rail hover expansion setting (#974) * fix: use correct translation key for nav.close (#964) * fix(tunnel): skip endpoint credential validation for direct tunnels (#963) * fix: SSH port connection bug (#975) * fix: chunked upload for files >=1.5GB to bypass browser ArrayBuffer limit (#948) * feat: support SSH agent auth across SSH features (#960) * feat: add Podman container runtime support (#958) * chore: update readme and release notes * fix: stabilize Windows app icon (#978) * Add safe host sharing export (#979) * Add external editor support for file manager (#985) * Rework SSH credential password handling (#984) * Support password fallback for SSH key credentials * Complete SSH credential password fallback * Fix runtime base path for auth callbacks (#982) * Fix TUI terminal output highlighting (#983) Co-authored-by: LukeGus <bugattiguy527@gmail.com> * Add app fullscreen mode (#993) * Fix host metrics startup polling (#986) * chore: update release notes * fix: line chart text overlap * chore: update RELEASE_NOTES.md * chore: add donation goal to readme * chore: update readme * fix: hide full-screen button in electron app * fix: failed unit tests * chore: lint, format, and bump version to 2.5.0 * chore: remove timeout from crowdin translate * chore: sync Crowdin translations for 2.5.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: DivByZero <mr.oplus@yahoo.fr> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com> Co-authored-by: sash <sash@fominykh.io> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
+218
-89
@@ -15,6 +15,7 @@ import { AppRail } from "@/sidebar/AppRail";
|
||||
import type { RailView } from "@/sidebar/AppRail";
|
||||
import { HostsPanel } from "@/sidebar/HostsPanel";
|
||||
import { QuickConnectPanel } from "@/sidebar/QuickConnectPanel";
|
||||
import { SerialPanel } from "@/sidebar/SerialPanel";
|
||||
import { SshToolsPanel } from "@/sidebar/SshToolsPanel";
|
||||
import { SnippetsPanel } from "@/sidebar/SnippetsPanel";
|
||||
import { HistoryPanel } from "@/sidebar/HistoryPanel";
|
||||
@@ -22,7 +23,9 @@ import { SessionLogsPanel } from "@/sidebar/SessionLogsPanel";
|
||||
import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel";
|
||||
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
|
||||
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
|
||||
import { AlertsPanel } from "@/sidebar/AlertsPanel";
|
||||
import { CredentialsPanel } from "@/sidebar/CredentialsPanel";
|
||||
import { TermixIdPanel } from "@/sidebar/TermixIdPanel";
|
||||
import { SplitView } from "@/shell/SplitView";
|
||||
import { renderTabContent } from "@/shell/tabUtils";
|
||||
import { AlertManager } from "@/dashboard/panels/alerts/AlertManager";
|
||||
@@ -35,6 +38,7 @@ import type {
|
||||
HostFolder,
|
||||
ThemeId,
|
||||
FontSizeId,
|
||||
SerialConfig,
|
||||
} from "@/types/ui-types";
|
||||
import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme";
|
||||
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||
@@ -54,67 +58,11 @@ import {
|
||||
} from "@/main-axios";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
||||
import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel";
|
||||
import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
|
||||
|
||||
function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
return {
|
||||
id: String(h.id),
|
||||
name: h.name,
|
||||
username: h.username,
|
||||
ip: h.ip,
|
||||
port: h.port,
|
||||
folder: h.folder ?? "",
|
||||
online: h.status === "online",
|
||||
cpu: 0,
|
||||
ram: 0,
|
||||
lastAccess: "",
|
||||
tags: h.tags ?? [],
|
||||
authType: h.authType,
|
||||
password: h.password,
|
||||
key: typeof h.key === "string" ? h.key : undefined,
|
||||
keyPassword: h.keyPassword,
|
||||
keyType: h.keyType,
|
||||
credentialId: h.credentialId != null ? String(h.credentialId) : undefined,
|
||||
notes: h.notes,
|
||||
pin: h.pin ?? false,
|
||||
macAddress: h.macAddress,
|
||||
enableSsh: h.enableSsh ?? (h.connectionType === "ssh" || !h.connectionType),
|
||||
enableTerminal: h.enableTerminal ?? true,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? true,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableProxmox: h.enableProxmox ?? false,
|
||||
enableTmuxMonitor: h.enableTmuxMonitor ?? false, // --- tmux-monitor ---
|
||||
proxmoxConfig: (h.proxmoxConfig as Host["proxmoxConfig"]) ?? null,
|
||||
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
|
||||
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
|
||||
enableTelnet: h.enableTelnet ?? h.connectionType === "telnet",
|
||||
sshPort: h.port,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
telnetPort: 23,
|
||||
quickActions: (h.quickActions ?? []).map((a) => ({
|
||||
name: a.name,
|
||||
snippetId: String(a.snippetId),
|
||||
})),
|
||||
jumpHosts: (h.jumpHosts ?? []).map((j) => ({
|
||||
hostId: String(j.hostId),
|
||||
})),
|
||||
serverTunnels: [],
|
||||
defaultPath: h.defaultPath,
|
||||
terminalConfig: h.terminalConfig as Host["terminalConfig"],
|
||||
useSocks5: h.useSocks5,
|
||||
socks5Host: h.socks5Host,
|
||||
socks5Port: h.socks5Port,
|
||||
socks5Username: h.socks5Username,
|
||||
socks5Password: h.socks5Password,
|
||||
socks5ProxyChain: h.socks5ProxyChain ?? [],
|
||||
statsConfig: (typeof h.statsConfig === "string"
|
||||
? JSON.parse(h.statsConfig)
|
||||
: h.statsConfig) as Host["statsConfig"],
|
||||
};
|
||||
}
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||
|
||||
function buildHostTree(
|
||||
hosts: SSHHostWithStatus[],
|
||||
@@ -221,6 +169,9 @@ export function AppShell({
|
||||
});
|
||||
const [sidebarDragging, setSidebarDragging] = useState(false);
|
||||
const [sidebarEditing, setSidebarEditing] = useState(false);
|
||||
const [isAppFullscreen, setIsAppFullscreen] = useState(
|
||||
() => !!document.fullscreenElement,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("termix_sidebarWidth", String(sidebarWidth));
|
||||
@@ -255,6 +206,34 @@ export function AppShell({
|
||||
.catch(() => setIsAdmin(false));
|
||||
}, []);
|
||||
|
||||
const toggleAppFullscreen = useCallback(async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.fullscreenEnabled) {
|
||||
toast.error("Fullscreen is not supported by this browser");
|
||||
return;
|
||||
}
|
||||
|
||||
await document.documentElement.requestFullscreen();
|
||||
} catch {
|
||||
toast.error("Unable to toggle fullscreen mode");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsAppFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
return () =>
|
||||
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
const lastShiftTime = useRef(0);
|
||||
const tabsRef = useRef(tabs);
|
||||
const activeTabIdRef = useRef(activeTabId);
|
||||
@@ -325,7 +304,9 @@ export function AppShell({
|
||||
const sidebarTitle: Record<RailView, string> = {
|
||||
hosts: "Hosts",
|
||||
credentials: "Credentials",
|
||||
"termix-id": t("nav.termixId"),
|
||||
"quick-connect": "Quick Connect",
|
||||
serial: t("nav.serial"),
|
||||
"ssh-tools": "SSH Tools",
|
||||
snippets: "Snippets",
|
||||
history: "History",
|
||||
@@ -334,6 +315,7 @@ export function AppShell({
|
||||
connections: t("nav.connections"),
|
||||
"user-profile": "User Profile",
|
||||
"admin-settings": "Admin Settings",
|
||||
alerts: t("nav.alerts"),
|
||||
};
|
||||
|
||||
// Double-shift opens command palette
|
||||
@@ -355,6 +337,14 @@ export function AppShell({
|
||||
// without going through synthetic DOM events (which are unreliable).
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||
if (e.code === "KeyF") {
|
||||
e.preventDefault();
|
||||
toggleAppFullscreen();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Shift+\ — toggle 2-way split (side by side)
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && e.code === "Backslash") {
|
||||
e.preventDefault();
|
||||
@@ -607,6 +597,7 @@ export function AppShell({
|
||||
"showHostTags",
|
||||
"hostTrayOnClick",
|
||||
"pinAppRail",
|
||||
"expandAppRailOnHover",
|
||||
"defaultSnippetFoldersCollapsed",
|
||||
"confirmSnippetExecution",
|
||||
"disableUpdateCheck",
|
||||
@@ -658,8 +649,20 @@ export function AppShell({
|
||||
"hostTrayOnClick",
|
||||
String(prefs.hostTrayOnClick),
|
||||
);
|
||||
if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined)
|
||||
if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined) {
|
||||
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
|
||||
window.dispatchEvent(new Event("pinAppRailChanged"));
|
||||
}
|
||||
if (
|
||||
prefs.expandAppRailOnHover !== null &&
|
||||
prefs.expandAppRailOnHover !== undefined
|
||||
) {
|
||||
localStorage.setItem(
|
||||
"expandAppRailOnHover",
|
||||
String(prefs.expandAppRailOnHover),
|
||||
);
|
||||
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
||||
}
|
||||
if (
|
||||
prefs.foldersCollapsed !== null &&
|
||||
prefs.foldersCollapsed !== undefined
|
||||
@@ -841,8 +844,11 @@ export function AppShell({
|
||||
host,
|
||||
openedAt: new Date(saved.createdAt).getTime(),
|
||||
restoredSessionId,
|
||||
terminalRef:
|
||||
saved.tabType === "terminal" ? createRef() : undefined,
|
||||
terminalRef: SESSION_TAB_TYPES.includes(
|
||||
saved.tabType as TabType,
|
||||
)
|
||||
? createRef()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -909,6 +915,7 @@ export function AppShell({
|
||||
restoredSessionId: string | null;
|
||||
savedLabel?: string;
|
||||
initialFilePath?: string;
|
||||
serialConfig?: SerialConfig;
|
||||
},
|
||||
) {
|
||||
const tabId = `${host.name}-${type}-${Date.now()}`;
|
||||
@@ -918,12 +925,13 @@ export function AppShell({
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`);
|
||||
const openedAt = Date.now();
|
||||
const ref = type === "terminal" ? createRef() : undefined;
|
||||
const ref = SESSION_TAB_TYPES.includes(type) ? createRef() : undefined;
|
||||
if (ref) terminalRefs.current.set(tabId, ref);
|
||||
|
||||
let finalLabel = host.name;
|
||||
const savedLabel = restore?.savedLabel;
|
||||
const initialFilePath = restore?.initialFilePath;
|
||||
const serialConfig = restore?.serialConfig;
|
||||
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
||||
const isCustomLabel =
|
||||
savedLabel != null &&
|
||||
@@ -946,6 +954,7 @@ export function AppShell({
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
initialFilePath,
|
||||
serialConfig,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -977,6 +986,7 @@ export function AppShell({
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
initialFilePath,
|
||||
serialConfig,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -994,17 +1004,7 @@ export function AppShell({
|
||||
}, []);
|
||||
|
||||
function connectHost(host: Host, preferredType?: TabType) {
|
||||
const type: TabType =
|
||||
preferredType ??
|
||||
(host.enableSsh
|
||||
? "terminal"
|
||||
: host.enableRdp
|
||||
? "rdp"
|
||||
: host.enableVnc
|
||||
? "vnc"
|
||||
: host.enableTelnet
|
||||
? "telnet"
|
||||
: "terminal");
|
||||
const type = resolveHostTabType(host, preferredType);
|
||||
// --- tmux-monitor --- singleton tab, not a per-host tab
|
||||
if (type === "tmux_monitor") {
|
||||
openSingletonTab(type, undefined, host);
|
||||
@@ -1013,6 +1013,50 @@ export function AppShell({
|
||||
openTab(host, type);
|
||||
}
|
||||
|
||||
function openSerialTab(config: SerialConfig) {
|
||||
const pseudoHost: Host = {
|
||||
id: `serial-${Date.now()}`,
|
||||
name: config.path
|
||||
? `${config.path} (${config.baudRate})`
|
||||
: `Serial (${config.baudRate})`,
|
||||
username: "",
|
||||
ip: "",
|
||||
port: 0,
|
||||
folder: "",
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: new Date().toISOString(),
|
||||
authType: "none",
|
||||
enableTerminal: false,
|
||||
enableCommandHistory: false,
|
||||
enableTunnel: false,
|
||||
enableFileManager: false,
|
||||
enableDocker: false,
|
||||
enableProxmox: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableSsh: false,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: 22,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
telnetPort: 23,
|
||||
serverTunnels: [],
|
||||
quickActions: [],
|
||||
};
|
||||
const instanceId =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
openTab(pseudoHost, "serial", {
|
||||
instanceId,
|
||||
restoredSessionId: null,
|
||||
serialConfig: config,
|
||||
});
|
||||
}
|
||||
|
||||
const openSingletonTab = useCallback(
|
||||
// --- tmux-monitor --- (added optional `host` so tmux_monitor can open
|
||||
// with a preselected host; existing callers are unaffected)
|
||||
@@ -1060,6 +1104,7 @@ export function AppShell({
|
||||
tunnel: t("nav.tunnels"),
|
||||
network_graph: t("nav.networkGraph"),
|
||||
tmux_monitor: t("nav.tmuxMonitor"), // --- tmux-monitor ---
|
||||
homepage: t("nav.homepage"),
|
||||
};
|
||||
setTabs((prev) => {
|
||||
const existing = prev.find((t) => t.id === id);
|
||||
@@ -1094,7 +1139,42 @@ export function AppShell({
|
||||
[t],
|
||||
);
|
||||
|
||||
const SESSION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"];
|
||||
const SESSION_TAB_TYPES: TabType[] = [
|
||||
"terminal",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"telnet",
|
||||
"serial",
|
||||
];
|
||||
const ACTIVE_CLOSE_CONFIRM_TYPES: TabType[] = SESSION_TAB_TYPES;
|
||||
|
||||
const getTabCloseLabel = useCallback((tab: Tab) => {
|
||||
return tab.customLabel || tab.label || tab.host?.name || String(tab.id);
|
||||
}, []);
|
||||
|
||||
const isActiveConnectionTab = useCallback((tab: Tab) => {
|
||||
if (!ACTIVE_CLOSE_CONFIRM_TYPES.includes(tab.type)) return false;
|
||||
return tab.terminalRef?.current?.isConnected?.() === true;
|
||||
}, []);
|
||||
|
||||
const hasActiveConnection = useCallback(() => {
|
||||
return tabsRef.current.some(isActiveConnectionTab);
|
||||
}, [isActiveConnectionTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!hasActiveConnection()) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
return "";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [hasActiveConnection]);
|
||||
|
||||
function doCloseTab(id: string) {
|
||||
const tabToClose = tabs.find((t) => t.id === id);
|
||||
@@ -1148,20 +1228,37 @@ export function AppShell({
|
||||
function closeTab(id: string) {
|
||||
const tab = tabs.find((t) => t.id === id);
|
||||
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
||||
if (tab && SESSION_TAB_TYPES.includes(tab.type) && confirmEnabled) {
|
||||
toast(t("nav.confirmClose"), {
|
||||
duration: 5000,
|
||||
action: {
|
||||
label: t("nav.close"),
|
||||
onClick: () => doCloseTab(id),
|
||||
if (tab && confirmEnabled && isActiveConnectionTab(tab)) {
|
||||
const closeLabel = getTabCloseLabel(tab);
|
||||
const toastId = `close-tab-${id}`;
|
||||
toast(
|
||||
t("nav.confirmCloseHost", {
|
||||
host: closeLabel,
|
||||
defaultValue: `Close ${closeLabel}?`,
|
||||
}),
|
||||
{
|
||||
id: toastId,
|
||||
duration: 8000,
|
||||
action: {
|
||||
label: t("nav.close"),
|
||||
onClick: () => {
|
||||
toast.dismiss(toastId);
|
||||
doCloseTab(id);
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: t("nav.cancel"),
|
||||
onClick: () => toast.dismiss(toastId),
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: t("nav.cancel"),
|
||||
onClick: () => {},
|
||||
},
|
||||
});
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tab && SESSION_TAB_TYPES.includes(tab.type) && confirmEnabled) {
|
||||
toast.dismiss(`close-tab-${id}`);
|
||||
}
|
||||
|
||||
doCloseTab(id);
|
||||
}
|
||||
|
||||
@@ -1366,6 +1463,21 @@ export function AppShell({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{railView === "termix-id" && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<TermixIdPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "serial" && (
|
||||
<SerialPanel
|
||||
onConnect={(config) => {
|
||||
openSerialTab(config);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{railView === "quick-connect" && (
|
||||
<QuickConnectPanel
|
||||
onConnect={(host, type) => {
|
||||
@@ -1481,6 +1593,12 @@ export function AppShell({
|
||||
<AdminSettingsPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "alerts" && (
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<AlertsPanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1517,7 +1635,7 @@ export function AppShell({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ServerStatusProvider isAuthenticated={!!username}>
|
||||
<div className="flex w-screen bg-background" style={{ height: "100dvh" }}>
|
||||
{/* Skinny icon rail — desktop only, hidden on mobile */}
|
||||
<AppRail
|
||||
@@ -1595,6 +1713,8 @@ export function AppShell({
|
||||
onAddToSplit={addTabToSplit}
|
||||
onRemoveFromSplit={removeTabFromSplit}
|
||||
onRenameTab={renameTab}
|
||||
isAppFullscreen={isAppFullscreen}
|
||||
onToggleAppFullscreen={toggleAppFullscreen}
|
||||
/>
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
||||
@@ -1658,7 +1778,16 @@ export function AppShell({
|
||||
restoredSessionId: null,
|
||||
initialFilePath: filePath,
|
||||
}),
|
||||
(host, path) => openTab(host, "files"),
|
||||
(host, _path) => openTab(host, "files"),
|
||||
(host, path) =>
|
||||
openTab(host, "terminal", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialFilePath: path,
|
||||
}),
|
||||
renameTab,
|
||||
),
|
||||
tabNode,
|
||||
@@ -1708,6 +1837,6 @@ export function AppShell({
|
||||
/>
|
||||
<TransferMonitor />
|
||||
<AlertManager userId={userId} loggedIn={!!username} />
|
||||
</>
|
||||
</ServerStatusProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { rbacApi } from "@/main-axios";
|
||||
|
||||
export interface NotificationChannel {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
type: "webhook" | "ntfy";
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AlertRule {
|
||||
id: number;
|
||||
userId: string;
|
||||
hostId: number | null;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
triggerType: string;
|
||||
thresholdValue: number | null;
|
||||
thresholdDurationSeconds: number | null;
|
||||
cooldownMinutes: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
channelIds: number[];
|
||||
}
|
||||
|
||||
export interface AlertFiring {
|
||||
id: number;
|
||||
userId: string;
|
||||
ruleId: number;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
firedAt: string;
|
||||
resolvedAt: string | null;
|
||||
value: number | null;
|
||||
message: string;
|
||||
severity: "info" | "warning" | "critical";
|
||||
acknowledged: boolean;
|
||||
ruleName?: string;
|
||||
}
|
||||
|
||||
export async function getNotificationChannels(): Promise<
|
||||
NotificationChannel[]
|
||||
> {
|
||||
const res = await rbacApi.get("/notification-channels");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createNotificationChannel(
|
||||
data: Partial<NotificationChannel>,
|
||||
): Promise<NotificationChannel> {
|
||||
const res = await rbacApi.post("/notification-channels", data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateNotificationChannel(
|
||||
id: number,
|
||||
data: Partial<NotificationChannel>,
|
||||
): Promise<NotificationChannel> {
|
||||
const res = await rbacApi.put(`/notification-channels/${id}`, data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteNotificationChannel(id: number): Promise<void> {
|
||||
await rbacApi.delete(`/notification-channels/${id}`);
|
||||
}
|
||||
|
||||
export async function testNotificationChannel(id: number): Promise<void> {
|
||||
await rbacApi.post(`/notification-channels/${id}/test`);
|
||||
}
|
||||
|
||||
function mapRule(r: Record<string, unknown>): AlertRule {
|
||||
return {
|
||||
id: r.id as number,
|
||||
userId: (r.user_id ?? r.userId) as string,
|
||||
hostId: (r.host_id ?? r.hostId ?? null) as number | null,
|
||||
name: r.name as string,
|
||||
enabled: Boolean(r.enabled),
|
||||
triggerType: (r.trigger_type ?? r.triggerType) as string,
|
||||
thresholdValue: (r.threshold_value ?? r.thresholdValue ?? null) as
|
||||
| number
|
||||
| null,
|
||||
thresholdDurationSeconds: (r.threshold_duration_seconds ??
|
||||
r.thresholdDurationSeconds ??
|
||||
null) as number | null,
|
||||
cooldownMinutes: (r.cooldown_minutes ?? r.cooldownMinutes) as number,
|
||||
createdAt: (r.created_at ?? r.createdAt) as string,
|
||||
updatedAt: (r.updated_at ?? r.updatedAt) as string,
|
||||
channelIds: Array.isArray(r.channels) ? (r.channels as number[]) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAlertRules(): Promise<AlertRule[]> {
|
||||
const res = await rbacApi.get("/alert-rules");
|
||||
return (res.data as Record<string, unknown>[]).map(mapRule);
|
||||
}
|
||||
|
||||
export async function createAlertRule(
|
||||
data: Partial<AlertRule> & { channels?: number[] },
|
||||
): Promise<AlertRule> {
|
||||
const res = await rbacApi.post("/alert-rules", data);
|
||||
return mapRule(res.data as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export async function updateAlertRule(
|
||||
id: number,
|
||||
data: Partial<AlertRule> & { channels?: number[] },
|
||||
): Promise<AlertRule> {
|
||||
const res = await rbacApi.put(`/alert-rules/${id}`, data);
|
||||
return mapRule(res.data as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export async function deleteAlertRule(id: number): Promise<void> {
|
||||
await rbacApi.delete(`/alert-rules/${id}`);
|
||||
}
|
||||
|
||||
export async function getAlertFirings(opts?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
acknowledged?: boolean;
|
||||
}): Promise<AlertFiring[]> {
|
||||
const res = await rbacApi.get("/alert-firings", { params: opts });
|
||||
return (res.data as { firings: AlertFiring[] }).firings ?? res.data;
|
||||
}
|
||||
|
||||
export async function acknowledgeAlertFiring(id: number): Promise<void> {
|
||||
await rbacApi.post(`/alert-firings/${id}/acknowledge`);
|
||||
}
|
||||
|
||||
export async function acknowledgeAllAlertFirings(): Promise<void> {
|
||||
await rbacApi.post("/alert-firings/acknowledge-all");
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export async function getSSHHostWithCredentials(
|
||||
|
||||
export async function getHostPassword(
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword" = "password",
|
||||
field: "password" | "sudoPassword" | "vncPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { dashboardApi, handleApiError } from "@/main-axios";
|
||||
import { normalizeServiceLinkUrl } from "@/lib/service-link-url";
|
||||
|
||||
// DASHBOARD API
|
||||
// ============================================================================
|
||||
@@ -105,7 +106,10 @@ export async function createServiceLink(
|
||||
url: string,
|
||||
): Promise<ServiceLink> {
|
||||
try {
|
||||
const response = await dashboardApi.post("/service-links", { label, url });
|
||||
const response = await dashboardApi.post("/service-links", {
|
||||
label,
|
||||
url: normalizeServiceLinkUrl(url),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create service link");
|
||||
@@ -128,7 +132,13 @@ export async function updateServiceLink(
|
||||
updates: { label?: string; url?: string },
|
||||
): Promise<ServiceLink> {
|
||||
try {
|
||||
const response = await dashboardApi.put(`/service-links/${id}`, updates);
|
||||
const response = await dashboardApi.put(`/service-links/${id}`, {
|
||||
...updates,
|
||||
url:
|
||||
updates.url !== undefined
|
||||
? normalizeServiceLinkUrl(updates.url)
|
||||
: undefined,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update service link");
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { homepageApi, handleApiError } from "@/main-axios";
|
||||
import type {
|
||||
HomepageItemRow,
|
||||
HomepageLayoutData,
|
||||
HomepageLayoutRow,
|
||||
WidgetTypeId,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export async function getHomepageItems(): Promise<HomepageItemRow[]> {
|
||||
try {
|
||||
const res = await homepageApi.get("/items");
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch homepage items");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHomepageItem(data: {
|
||||
typeId: WidgetTypeId;
|
||||
title?: string | null;
|
||||
config?: Record<string, unknown>;
|
||||
}): Promise<HomepageItemRow> {
|
||||
try {
|
||||
const res = await homepageApi.post("/items", data);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create homepage item");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHomepageItem(
|
||||
id: number,
|
||||
data: {
|
||||
title?: string | null;
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<HomepageItemRow> {
|
||||
try {
|
||||
const res = await homepageApi.put(`/items/${id}`, data);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update homepage item");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHomepageItem(id: number): Promise<void> {
|
||||
try {
|
||||
await homepageApi.delete(`/items/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete homepage item");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHomepageLayout(): Promise<HomepageLayoutRow | null> {
|
||||
try {
|
||||
const res = await homepageApi.get("/layout");
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch homepage layout");
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHomepageLayout(
|
||||
layout: HomepageLayoutData,
|
||||
): Promise<HomepageLayoutRow> {
|
||||
try {
|
||||
const res = await homepageApi.put("/layout", layout);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "save homepage layout");
|
||||
}
|
||||
}
|
||||
|
||||
export function getHomepageFaviconUrl(url: string): string {
|
||||
try {
|
||||
const base = (homepageApi.defaults.baseURL ?? "").replace(/\/$/, "");
|
||||
return `${base}/favicon?url=${encodeURIComponent(url)}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,43 @@
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import type { HostMetricsLayout } from "@/types/host-metrics";
|
||||
|
||||
export interface MetricsHistoryRow {
|
||||
ts: string;
|
||||
cpu_percent: number | null;
|
||||
mem_percent: number | null;
|
||||
disk_percent: number | null;
|
||||
net_rx_bytes: number | null;
|
||||
net_tx_bytes: number | null;
|
||||
}
|
||||
|
||||
export interface MetricsHistoryResponse {
|
||||
rows: MetricsHistoryRow[];
|
||||
fromTs: string;
|
||||
toTs: string;
|
||||
}
|
||||
|
||||
export async function getMetricsHistory(
|
||||
hostId: number,
|
||||
opts: { range?: string; from?: string; to?: string },
|
||||
): Promise<MetricsHistoryResponse> {
|
||||
const res = await statsApi.get(`/metrics/history/${hostId}`, {
|
||||
params: opts,
|
||||
});
|
||||
return res.data as MetricsHistoryResponse;
|
||||
}
|
||||
|
||||
export async function getMetricsHistoryRetention(): Promise<number> {
|
||||
const res = await statsApi.get("/global-settings/history");
|
||||
return (res.data as { metricsHistoryRetentionDays: number })
|
||||
.metricsHistoryRetentionDays;
|
||||
}
|
||||
|
||||
export async function saveMetricsHistoryRetention(days: number): Promise<void> {
|
||||
await statsApi.post("/global-settings/history", {
|
||||
metricsHistoryRetentionDays: days,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Host Metrics layout persistence (server-synced per user/host) + the manager
|
||||
* card endpoints. All routes live under the `/host-metrics/*` prefix on the
|
||||
|
||||
@@ -89,6 +89,7 @@ export interface UserPreferences {
|
||||
showHostTags?: boolean | null;
|
||||
hostTrayOnClick?: boolean | null;
|
||||
pinAppRail?: boolean | null;
|
||||
expandAppRailOnHover?: boolean | null;
|
||||
foldersCollapsed?: boolean | null;
|
||||
confirmSnippetExecution?: boolean | null;
|
||||
disableUpdateCheck?: boolean | null;
|
||||
|
||||
@@ -24,6 +24,27 @@ type ConnectErrorResponse = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
function buildFileManagerUrl(path: string): string {
|
||||
const baseURL = String(fileManagerApi.defaults.baseURL || "");
|
||||
return `${baseURL.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.style.display = "none";
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
window.setTimeout(() => {
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// SSH FILE OPERATIONS
|
||||
// ============================================================================
|
||||
|
||||
@@ -348,8 +369,74 @@ export async function uploadSSHFile(
|
||||
file: File,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
onChunkProgress?: (progress: {
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
bytesSent: number;
|
||||
totalBytes: number;
|
||||
}) => void,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// Browser-side safety: any single multipart body approaching 2^31 bytes (~2.14GB)
|
||||
// crashes the XHR/ArrayBuffer pipeline in both Chromium (Electron) and Firefox,
|
||||
// surfacing as "DOMException: File could not be read" inside FileManager-*.js.
|
||||
// For larger files we slice into <=8MB chunks and upload each one separately,
|
||||
// never materializing the whole file in JS memory.
|
||||
const CHUNK_THRESHOLD_BYTES = 1.5 * 1024 * 1024 * 1024; // 1.5 GiB
|
||||
const CHUNK_SIZE_BYTES = 8 * 1024 * 1024; // 8 MiB
|
||||
|
||||
try {
|
||||
if (file.size > CHUNK_THRESHOLD_BYTES) {
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE_BYTES);
|
||||
fileLogger.info("Starting chunked upload", {
|
||||
operation: "file_upload_chunked_start",
|
||||
fileName,
|
||||
fileSize: file.size,
|
||||
totalChunks,
|
||||
chunkSize: CHUNK_SIZE_BYTES,
|
||||
});
|
||||
|
||||
let bytesSent = 0;
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * CHUNK_SIZE_BYTES;
|
||||
const end = Math.min(start + CHUNK_SIZE_BYTES, file.size);
|
||||
const chunkBlob = file.slice(start, end);
|
||||
|
||||
const form = new FormData();
|
||||
form.append("sessionId", sessionId);
|
||||
form.append("path", path);
|
||||
form.append("fileName", fileName);
|
||||
form.append("chunkIndex", String(i));
|
||||
form.append("totalChunks", String(totalChunks));
|
||||
form.append("totalSize", String(file.size));
|
||||
form.append("chunk", chunkBlob, fileName);
|
||||
|
||||
const response = await fileManagerApi.post(
|
||||
"/ssh/uploadFileChunk",
|
||||
form,
|
||||
{ timeout: 0 },
|
||||
);
|
||||
|
||||
bytesSent = end;
|
||||
onChunkProgress?.({
|
||||
chunkIndex: i,
|
||||
totalChunks,
|
||||
bytesSent,
|
||||
totalBytes: file.size,
|
||||
});
|
||||
|
||||
if (i === totalChunks - 1) {
|
||||
fileLogger.success("Chunked upload completed", {
|
||||
operation: "file_upload_chunked_complete",
|
||||
fileName,
|
||||
fileSize: file.size,
|
||||
totalChunks,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
return { message: "File uploaded successfully", chunked: true };
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append("sessionId", sessionId);
|
||||
form.append("path", path);
|
||||
@@ -357,9 +444,13 @@ export async function uploadSSHFile(
|
||||
if (userId !== undefined) form.append("userId", userId);
|
||||
form.append("file", file, fileName);
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/uploadFileStream", form, {
|
||||
timeout: 0,
|
||||
});
|
||||
const response = await fileManagerApi.postForm(
|
||||
"/ssh/uploadFileStream",
|
||||
form,
|
||||
{
|
||||
timeout: 0,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "upload SSH file");
|
||||
@@ -400,12 +491,7 @@ export async function downloadSSHFileStream(
|
||||
);
|
||||
const blob = response.data as Blob;
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
triggerBlobDownload(blob, fileName);
|
||||
}
|
||||
|
||||
export async function createSSHFile(
|
||||
|
||||
@@ -88,6 +88,7 @@ export async function wakeOnLan(hostId: number): Promise<{ success: boolean }> {
|
||||
export async function bulkImportSSHHosts(
|
||||
hosts: SSHHostData[],
|
||||
overwrite = false,
|
||||
credentials?: Record<string, unknown>[],
|
||||
): Promise<{
|
||||
message: string;
|
||||
success: number;
|
||||
@@ -100,6 +101,7 @@ export async function bulkImportSSHHosts(
|
||||
const response = await sshHostApi.post("/bulk-import", {
|
||||
hosts,
|
||||
overwrite,
|
||||
...(credentials ? { credentials } : {}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -190,11 +192,27 @@ export async function exportSSHHostWithCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportAllSSHHosts(): Promise<{
|
||||
export function exportAllSSHHosts(): Promise<{
|
||||
hosts: SSHHost[];
|
||||
}>;
|
||||
export function exportAllSSHHosts(options: { share: true }): Promise<{
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
credentials: Record<string, unknown>[];
|
||||
hosts: SSHHost[];
|
||||
}>;
|
||||
export async function exportAllSSHHosts(options?: {
|
||||
share?: boolean;
|
||||
}): Promise<{
|
||||
version?: string;
|
||||
exportedAt?: string;
|
||||
credentials?: Record<string, unknown>[];
|
||||
hosts: SSHHost[];
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.get("/db/hosts/export");
|
||||
const response = await sshHostApi.get(
|
||||
options?.share ? "/db/hosts/export?share=1" : "/db/hosts/export",
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "export all SSH hosts");
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface TermixIdentity {
|
||||
id: number;
|
||||
userId: string;
|
||||
handle: string;
|
||||
description: string | null;
|
||||
resolverPath?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TermixIdentityKey {
|
||||
id: number;
|
||||
identityId: number;
|
||||
userId: string;
|
||||
publicKey: string;
|
||||
keyType: string;
|
||||
algorithm: string;
|
||||
label: string | null;
|
||||
comment: string | null;
|
||||
source: string;
|
||||
credentialId: number | null;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TermixIdMe {
|
||||
identity: TermixIdentity | null;
|
||||
keys: TermixIdentityKey[];
|
||||
}
|
||||
|
||||
export async function getMyTermixId(): Promise<TermixIdMe> {
|
||||
try {
|
||||
const response = await authApi.get("/termix-id/me");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch Termix ID");
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkTermixIdHandle(
|
||||
handle: string,
|
||||
): Promise<{ available: boolean; valid: boolean }> {
|
||||
try {
|
||||
const response = await authApi.get(
|
||||
`/termix-id/check/${encodeURIComponent(handle)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "check handle");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTermixId(
|
||||
handle: string,
|
||||
description?: string,
|
||||
): Promise<TermixIdentity> {
|
||||
try {
|
||||
const response = await authApi.post("/termix-id", { handle, description });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create Termix ID");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTermixId(data: {
|
||||
handle?: string;
|
||||
description?: string;
|
||||
}): Promise<TermixIdentity> {
|
||||
try {
|
||||
const response = await authApi.put("/termix-id", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update Termix ID");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTermixId(): Promise<void> {
|
||||
try {
|
||||
await authApi.delete("/termix-id");
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete Termix ID");
|
||||
}
|
||||
}
|
||||
|
||||
export async function addTermixIdKey(data: {
|
||||
publicKey?: string;
|
||||
credentialId?: number;
|
||||
label?: string;
|
||||
}): Promise<TermixIdentityKey> {
|
||||
try {
|
||||
const response = await authApi.post("/termix-id/keys", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "add key");
|
||||
}
|
||||
}
|
||||
|
||||
export interface GeneratedKey {
|
||||
key: TermixIdentityKey;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
credentialId: number | null;
|
||||
}
|
||||
|
||||
export async function generateTermixIdKey(
|
||||
type: "ed25519" | "rsa" = "ed25519",
|
||||
saveCredential = true,
|
||||
): Promise<GeneratedKey> {
|
||||
try {
|
||||
const response = await authApi.post("/termix-id/keys/generate", {
|
||||
type,
|
||||
saveCredential,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "generate key");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setTermixIdKeyEnabled(
|
||||
id: number,
|
||||
enabled: boolean,
|
||||
): Promise<TermixIdentityKey> {
|
||||
try {
|
||||
const response = await authApi.patch(`/termix-id/keys/${id}`, { enabled });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update key");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTermixIdKey(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/termix-id/keys/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete key");
|
||||
}
|
||||
}
|
||||
|
||||
export interface TermixIdCa {
|
||||
publicKey: string;
|
||||
validityDays: number;
|
||||
resolverPath: string;
|
||||
}
|
||||
|
||||
export interface IssuedCertificate {
|
||||
certificate: string;
|
||||
keyId: string;
|
||||
validBefore: number;
|
||||
principals: string[];
|
||||
validityDays: number;
|
||||
}
|
||||
|
||||
export async function getMyCa(): Promise<{ ca: TermixIdCa | null }> {
|
||||
try {
|
||||
const response = await authApi.get("/termix-id/ca");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch CA");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCa(validityDays?: number): Promise<TermixIdCa> {
|
||||
try {
|
||||
const response = await authApi.post("/termix-id/ca", { validityDays });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create CA");
|
||||
}
|
||||
}
|
||||
|
||||
export async function rotateCa(validityDays?: number): Promise<TermixIdCa> {
|
||||
try {
|
||||
const response = await authApi.post("/termix-id/ca/rotate", {
|
||||
validityDays,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "rotate CA");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCa(): Promise<void> {
|
||||
try {
|
||||
await authApi.delete("/termix-id/ca");
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete CA");
|
||||
}
|
||||
}
|
||||
|
||||
export async function issueCertificate(
|
||||
keyId: number,
|
||||
opts: { principals?: string[]; validityDays?: number } = {},
|
||||
): Promise<IssuedCertificate> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/termix-id/keys/${keyId}/certificate`,
|
||||
opts,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "issue certificate");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLinkedCredentialIds(): Promise<{
|
||||
credentialIds: number[];
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/termix-id/linked-credentials");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch linked credentials");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface VaultProfilePayload {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
tags?: string[];
|
||||
vaultAddr: string;
|
||||
vaultNamespace?: string | null;
|
||||
oidcMount?: string | null;
|
||||
oidcRole?: string | null;
|
||||
sshMount?: string | null;
|
||||
sshRole: string;
|
||||
validPrincipals?: string | null;
|
||||
keyType?: string | null;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
export async function getVaultProfiles(): Promise<Record<string, unknown>[]> {
|
||||
try {
|
||||
const response = await authApi.get("/vault/profiles");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch vault profiles");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createVaultProfile(
|
||||
payload: VaultProfilePayload,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/vault/profiles", payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create vault profile");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateVaultProfile(
|
||||
id: number,
|
||||
payload: Partial<VaultProfilePayload>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.put(`/vault/profiles/${id}`, payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update vault profile");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteVaultProfile(
|
||||
id: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete(`/vault/profiles/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete vault profile");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/browser";
|
||||
import {
|
||||
startAuthentication,
|
||||
startRegistration,
|
||||
} from "@simplewebauthn/browser";
|
||||
import { authApi, handleApiError, type AuthResponse } from "@/main-axios";
|
||||
|
||||
export type WebAuthnUserVerification = "discouraged" | "preferred" | "required";
|
||||
|
||||
export type WebAuthnCredentialSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
deviceType?: string | null;
|
||||
backedUp: boolean;
|
||||
transports: string[];
|
||||
userVerification: WebAuthnUserVerification;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string | null;
|
||||
};
|
||||
|
||||
type RegistrationOptionsResponse = {
|
||||
options: PublicKeyCredentialCreationOptionsJSON;
|
||||
challengeId: string;
|
||||
};
|
||||
|
||||
type AuthenticationOptionsResponse = {
|
||||
options: PublicKeyCredentialRequestOptionsJSON;
|
||||
challengeId: string;
|
||||
};
|
||||
|
||||
export async function listWebAuthnCredentials(): Promise<{
|
||||
credentials: WebAuthnCredentialSummary[];
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/webauthn/credentials");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list passkeys");
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerWebAuthnCredential(
|
||||
name: string,
|
||||
userVerification: WebAuthnUserVerification,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const optionsResponse = await authApi.post<RegistrationOptionsResponse>(
|
||||
"/users/webauthn/register/options",
|
||||
{ userVerification },
|
||||
);
|
||||
const credential = await startRegistration({
|
||||
optionsJSON: optionsResponse.data.options,
|
||||
});
|
||||
const verifyResponse = await authApi.post(
|
||||
"/users/webauthn/register/verify",
|
||||
{
|
||||
challengeId: optionsResponse.data.challengeId,
|
||||
name,
|
||||
response: credential as RegistrationResponseJSON,
|
||||
},
|
||||
);
|
||||
return verifyResponse.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "register passkey");
|
||||
}
|
||||
}
|
||||
|
||||
export async function authenticateWithWebAuthn(
|
||||
username: string,
|
||||
rememberMe: boolean,
|
||||
userVerification: WebAuthnUserVerification = "preferred",
|
||||
): Promise<AuthResponse> {
|
||||
try {
|
||||
const optionsResponse = await authApi.post<AuthenticationOptionsResponse>(
|
||||
"/users/webauthn/authenticate/options",
|
||||
{
|
||||
username: username.trim() || undefined,
|
||||
userVerification,
|
||||
},
|
||||
);
|
||||
const credential = await startAuthentication({
|
||||
optionsJSON: optionsResponse.data.options,
|
||||
});
|
||||
const verifyResponse = await authApi.post<AuthResponse>(
|
||||
"/users/webauthn/authenticate/verify",
|
||||
{
|
||||
challengeId: optionsResponse.data.challengeId,
|
||||
rememberMe,
|
||||
response: credential as AuthenticationResponseJSON,
|
||||
},
|
||||
);
|
||||
|
||||
if (verifyResponse.data.token) {
|
||||
localStorage.setItem("jwt", verifyResponse.data.token);
|
||||
}
|
||||
|
||||
return verifyResponse.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "authenticate with passkey");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWebAuthnCredential(
|
||||
credentialId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.delete(
|
||||
`/users/webauthn/credentials/${credentialId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete passkey");
|
||||
}
|
||||
}
|
||||
@@ -278,6 +278,27 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
}
|
||||
}, [rememberMe]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInElectronWebView()) return;
|
||||
|
||||
const handleOIDCSystemBrowserResult = (event: MessageEvent) => {
|
||||
if (event.source !== window.parent) return;
|
||||
if (!event.data || typeof event.data !== "object") return;
|
||||
if (event.data.type !== "OIDC_SYSTEM_BROWSER_AUTH_RESULT") return;
|
||||
|
||||
const providerId =
|
||||
typeof event.data.providerId === "number" ? event.data.providerId : -1;
|
||||
if (event.data.success) return;
|
||||
|
||||
setProviderLoading((prev) => ({ ...prev, [providerId]: false }));
|
||||
toast.error(event.data.error || t("errors.failedOidcLogin"));
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleOIDCSystemBrowserResult);
|
||||
return () =>
|
||||
window.removeEventListener("message", handleOIDCSystemBrowserResult);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
getRegistrationAllowed()
|
||||
.then((res) => setRegistrationAllowed(res.allowed))
|
||||
@@ -770,6 +791,7 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
source: "oidc_request",
|
||||
authUrl,
|
||||
callbackPort,
|
||||
providerId: loadingKey,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
@@ -63,8 +63,15 @@ export function ElectronLoginForm({
|
||||
try {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (!event.data || typeof event.data !== "object") return;
|
||||
const { type, platform, source, token, authUrl, callbackPort } =
|
||||
event.data;
|
||||
const {
|
||||
type,
|
||||
platform,
|
||||
source,
|
||||
token,
|
||||
authUrl,
|
||||
callbackPort,
|
||||
providerId,
|
||||
} = event.data;
|
||||
|
||||
if (
|
||||
type === "AUTH_SUCCESS" &&
|
||||
@@ -78,6 +85,20 @@ export function ElectronLoginForm({
|
||||
// OIDC login requested from inside the iframe — open the system browser
|
||||
// so captcha stages (e.g. Cloudflare Turnstile) render correctly.
|
||||
if (type === "OIDC_SYSTEM_BROWSER_AUTH" && authUrl && callbackPort) {
|
||||
const sendResultToIframe = (result: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
|
||||
source: "oidc_system_browser_auth",
|
||||
providerId,
|
||||
...result,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
};
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: {
|
||||
@@ -92,23 +113,51 @@ export function ElectronLoginForm({
|
||||
};
|
||||
}
|
||||
).electronAPI;
|
||||
if (!electronAPI?.oidcSystemBrowserAuth) return;
|
||||
if (!electronAPI?.oidcSystemBrowserAuth) {
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
error: t("errors.failedOidcLogin"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await electronAPI.oidcSystemBrowserAuth(
|
||||
authUrl,
|
||||
callbackPort,
|
||||
);
|
||||
if (result.success && result.token) {
|
||||
await handleAuthSuccess(result.token);
|
||||
return;
|
||||
}
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
error: result.error || t("errors.failedOidcLogin"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
const providerId =
|
||||
event.data &&
|
||||
typeof event.data === "object" &&
|
||||
typeof event.data.providerId === "number"
|
||||
? event.data.providerId
|
||||
: undefined;
|
||||
const error =
|
||||
err instanceof Error ? err.message : t("errors.failedOidcLogin");
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
|
||||
source: "oidc_system_browser_auth",
|
||||
providerId,
|
||||
success: false,
|
||||
error,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [handleAuthSuccess]);
|
||||
}, [handleAuthSuccess, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
|
||||
+120
-4
@@ -32,6 +32,7 @@ import {
|
||||
getOidcSilentLoginDefault,
|
||||
} from "@/main-axios";
|
||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||
import { authenticateWithWebAuthn } from "@/api/webauthn-api";
|
||||
import type { SSOProviderPublic } from "@/types/index";
|
||||
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig.tsx";
|
||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
|
||||
@@ -117,6 +118,7 @@ export function Auth({
|
||||
}
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
|
||||
const [firstUser, setFirstUser] = useState(false);
|
||||
@@ -657,6 +659,73 @@ export function Auth({
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
setPasskeyLoading(true);
|
||||
try {
|
||||
const res = await authenticateWithWebAuthn(
|
||||
localUsername,
|
||||
rememberMe,
|
||||
"preferred",
|
||||
);
|
||||
|
||||
if (res.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpTempToken(res.temp_token || "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res || !res.success) {
|
||||
throw new Error(t("errors.loginFailed"));
|
||||
}
|
||||
|
||||
if (isInMobileWebView()) {
|
||||
postMobileAuthSuccess(res.token || "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInElectronWebView()) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
source: "passkey_auth_component",
|
||||
platform: "desktop",
|
||||
token: res.token || null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const meRes = await getUserInfo();
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
toast.success(t("messages.loginSuccess"));
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
message?: string;
|
||||
response?: { data?: { error?: string } };
|
||||
};
|
||||
toast.error(
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
t("auth.passkeyLoginFailed"),
|
||||
);
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleOIDCLogin = useCallback(
|
||||
async (providerId?: number) => {
|
||||
setOidcLoading(true);
|
||||
@@ -1320,8 +1389,10 @@ export function Auth({
|
||||
const hasSignup =
|
||||
(passwordLoginAllowed || firstUser) &&
|
||||
registrationAllowed;
|
||||
const hasPasskey = !firstUser;
|
||||
const hasSso = ssoProviders.length > 0;
|
||||
const hasAnyAuth = hasLogin || hasSignup || hasSso;
|
||||
const hasAnyAuth =
|
||||
hasLogin || hasSignup || hasPasskey || hasSso;
|
||||
|
||||
if (!hasAnyAuth) {
|
||||
return (
|
||||
@@ -1569,9 +1640,38 @@ export function Auth({
|
||||
{!passwordLoginAllowed &&
|
||||
!firstUser &&
|
||||
tab === "login" ? (
|
||||
<p className="text-center text-muted-foreground text-sm">
|
||||
{t("auth.passwordLoginDisabledDesc")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-center text-muted-foreground text-sm">
|
||||
{t("auth.passwordLoginDisabledDesc")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) =>
|
||||
setLocalUsername(e.target.value)
|
||||
}
|
||||
disabled={passkeyLoading || loggedIn}
|
||||
autoComplete="username webauthn"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={passkeyLoading || loggedIn}
|
||||
onClick={handlePasskeyLogin}
|
||||
>
|
||||
{passkeyLoading
|
||||
? Spinner
|
||||
: t("auth.signInWithPasskey")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -1588,6 +1688,7 @@ export function Auth({
|
||||
setLocalUsername(e.target.value)
|
||||
}
|
||||
disabled={loading || loggedIn}
|
||||
autoComplete="username webauthn"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -1666,6 +1767,21 @@ export function Auth({
|
||||
{t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
)}
|
||||
{tab === "login" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
loading || passkeyLoading || loggedIn
|
||||
}
|
||||
onClick={handlePasskeyLogin}
|
||||
>
|
||||
{passkeyLoading
|
||||
? Spinner
|
||||
: t("auth.signInWithPasskey")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface LineChartSeries {
|
||||
key: string;
|
||||
label: string;
|
||||
/** CSS color string or var(--...) */
|
||||
color: string;
|
||||
data: Array<number | null>;
|
||||
}
|
||||
|
||||
interface LineChartProps {
|
||||
series: LineChartSeries[];
|
||||
timestamps: string[];
|
||||
domain: [number, number];
|
||||
yFormatter?: (v: number) => string;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Y_LABEL_W = 44;
|
||||
const X_LABEL_H = 20;
|
||||
const TOP_PAD = 6;
|
||||
const TICK_COUNT = 5;
|
||||
|
||||
function parseTs(ts: string): Date {
|
||||
return new Date(ts.includes("T") ? ts : ts.replace(" ", "T") + "Z");
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string, rangeMs: number): string {
|
||||
const d = parseTs(ts);
|
||||
if (rangeMs <= 25 * 60 * 60 * 1000) {
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
return (
|
||||
d.toLocaleDateString([], { month: "short", day: "numeric" }) +
|
||||
" " +
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
);
|
||||
}
|
||||
|
||||
function valToY(v: number, drawH: number, domain: [number, number]): number {
|
||||
const [lo, hi] = domain;
|
||||
const range = hi === lo ? 1 : hi - lo;
|
||||
return (
|
||||
TOP_PAD + drawH - ((Math.min(hi, Math.max(lo, v)) - lo) / range) * drawH
|
||||
);
|
||||
}
|
||||
|
||||
function buildLinePath(
|
||||
data: Array<number | null>,
|
||||
w: number,
|
||||
drawH: number,
|
||||
domain: [number, number],
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
let inSeg = false;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const v = data[i];
|
||||
if (v == null) {
|
||||
inSeg = false;
|
||||
continue;
|
||||
}
|
||||
const x = (i / Math.max(1, data.length - 1)) * w;
|
||||
const y = valToY(v, drawH, domain);
|
||||
parts.push(
|
||||
inSeg
|
||||
? `L${x.toFixed(1)},${y.toFixed(1)}`
|
||||
: `M${x.toFixed(1)},${y.toFixed(1)}`,
|
||||
);
|
||||
inSeg = true;
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export function LineChart({
|
||||
series,
|
||||
timestamps,
|
||||
domain,
|
||||
yFormatter = (v) => String(v),
|
||||
height = 160,
|
||||
className,
|
||||
}: LineChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [tooltip, setTooltip] = useState<{ x: number; index: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) =>
|
||||
setWidth(entries[0].contentRect.width),
|
||||
);
|
||||
ro.observe(el);
|
||||
setWidth(el.clientWidth);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const chartW = Math.max(0, width - Y_LABEL_W);
|
||||
const drawH = height - TOP_PAD;
|
||||
const svgH = height + X_LABEL_H;
|
||||
|
||||
const rangeMs =
|
||||
timestamps.length >= 2
|
||||
? parseTs(timestamps[timestamps.length - 1]).getTime() -
|
||||
parseTs(timestamps[0]).getTime()
|
||||
: 0;
|
||||
|
||||
const yTicks = (() => {
|
||||
const [lo, hi] = domain;
|
||||
const step = (hi - lo) / (TICK_COUNT - 1);
|
||||
return Array.from({ length: TICK_COUNT }, (_, i) => lo + i * step);
|
||||
})();
|
||||
|
||||
const xTickIndices: number[] = [];
|
||||
if (timestamps.length > 1 && chartW > 0) {
|
||||
const n = timestamps.length;
|
||||
// Long labels ("Jun 28 14:30") are ~80px; short ("14:30") are ~40px.
|
||||
// Use at most 3 ticks for long labels, 5 for short, so they never crowd.
|
||||
const longLabel = rangeMs > 25 * 60 * 60 * 1000;
|
||||
const maxTicks = longLabel ? 3 : 5;
|
||||
const count = Math.min(maxTicks, n);
|
||||
for (let k = 0; k < count; k++) {
|
||||
xTickIndices.push(Math.round((k / (count - 1)) * (n - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<SVGElement>) => {
|
||||
if (timestamps.length < 2 || chartW <= 0) return;
|
||||
const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
|
||||
const relX = e.clientX - rect.left - Y_LABEL_W;
|
||||
const index = Math.round(
|
||||
Math.max(0, Math.min(1, relX / chartW)) * (timestamps.length - 1),
|
||||
);
|
||||
setTooltip({ x: e.clientX - rect.left, index });
|
||||
},
|
||||
[timestamps.length, chartW],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("relative select-none w-full", className)}
|
||||
>
|
||||
{width > 0 && (
|
||||
<svg
|
||||
width={width}
|
||||
height={svgH}
|
||||
style={{ display: "block" }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
>
|
||||
{/* Grid lines + Y labels */}
|
||||
{yTicks.map((tick, ti) => {
|
||||
const y = valToY(tick, drawH, domain);
|
||||
return (
|
||||
<g key={ti}>
|
||||
<line
|
||||
x1={Y_LABEL_W}
|
||||
y1={y}
|
||||
x2={width}
|
||||
y2={y}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
<text
|
||||
x={Y_LABEL_W - 6}
|
||||
y={y + 4}
|
||||
fontSize={10}
|
||||
textAnchor="end"
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{yFormatter(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Left + bottom border */}
|
||||
<line
|
||||
x1={Y_LABEL_W}
|
||||
y1={TOP_PAD}
|
||||
x2={Y_LABEL_W}
|
||||
y2={TOP_PAD + drawH}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<line
|
||||
x1={Y_LABEL_W}
|
||||
y1={TOP_PAD + drawH}
|
||||
x2={width}
|
||||
y2={TOP_PAD + drawH}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
|
||||
{/* Series */}
|
||||
{chartW > 0 &&
|
||||
series.map((s) => {
|
||||
const d = buildLinePath(s.data, chartW, drawH, domain);
|
||||
if (!d) return null;
|
||||
return (
|
||||
<path
|
||||
key={s.key}
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
transform={`translate(${Y_LABEL_W},0)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Crosshair */}
|
||||
{tooltip !== null && timestamps.length > 1 && chartW > 0 && (
|
||||
<line
|
||||
x1={
|
||||
Y_LABEL_W + (tooltip.index / (timestamps.length - 1)) * chartW
|
||||
}
|
||||
y1={TOP_PAD}
|
||||
x2={
|
||||
Y_LABEL_W + (tooltip.index / (timestamps.length - 1)) * chartW
|
||||
}
|
||||
y2={TOP_PAD + drawH}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeOpacity={0.4}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* X labels */}
|
||||
{xTickIndices.map((i, li) => {
|
||||
const isFirst = li === 0;
|
||||
const isLast = li === xTickIndices.length - 1;
|
||||
const x =
|
||||
Y_LABEL_W + (i / Math.max(1, timestamps.length - 1)) * chartW;
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={x}
|
||||
y={svgH - 4}
|
||||
fontSize={10}
|
||||
textAnchor={isFirst ? "start" : isLast ? "end" : "middle"}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{timestamps[i] ? formatTimestamp(timestamps[i], rangeMs) : ""}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip !== null && timestamps[tooltip.index] && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 border border-border bg-popover px-2 py-1.5 text-xs shadow-md"
|
||||
style={{
|
||||
left: tooltip.x > width / 2 ? tooltip.x - 8 : tooltip.x + 8,
|
||||
top: 28,
|
||||
transform: tooltip.x > width / 2 ? "translateX(-100%)" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 font-medium text-muted-foreground">
|
||||
{formatTimestamp(timestamps[tooltip.index], rangeMs)}
|
||||
</div>
|
||||
{series.map((s) => {
|
||||
const v = s.data[tooltip.index];
|
||||
return (
|
||||
<div key={s.key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-1.5 w-3 rounded-full shrink-0"
|
||||
style={{ background: s.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground">{s.label}:</span>
|
||||
<span className="font-medium">
|
||||
{v != null ? yFormatter(v) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/main-axios";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { ProxmoxGuest } from "@/types/proxmox";
|
||||
import { resolveProxmoxImportAuth } from "./proxmox-import-auth";
|
||||
|
||||
interface ProxmoxDiscoverDialogProps {
|
||||
open: boolean;
|
||||
@@ -118,8 +119,7 @@ export function ProxmoxDiscoverDialog({
|
||||
try {
|
||||
// Prefer explicitly configured credential, then fall back to the host's own credential
|
||||
const credId = defaultCredentialId ?? discoveredCredentialId;
|
||||
const resolvedAuthType =
|
||||
defaultAuthType ?? (credId != null ? "credential" : "password");
|
||||
const importAuth = resolveProxmoxImportAuth(defaultAuthType, credId);
|
||||
|
||||
const toImport = guests
|
||||
.filter((g) => selected.has(g.vmid))
|
||||
@@ -129,13 +129,7 @@ export function ProxmoxDiscoverDialog({
|
||||
port: g.connectionType === "rdp" ? 3389 : 22,
|
||||
username: defaultUsername ?? "root",
|
||||
folder: importFolder,
|
||||
authType: resolvedAuthType,
|
||||
...(resolvedAuthType === "credential" && credId != null
|
||||
? {
|
||||
credentialId: credId,
|
||||
overrideCredentialUsername: true,
|
||||
}
|
||||
: {}),
|
||||
...importAuth,
|
||||
enableTerminal: g.connectionType !== "rdp",
|
||||
enableFileManager: g.connectionType !== "rdp",
|
||||
enableTunnel: g.connectionType !== "rdp",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveProxmoxImportAuth } from "./proxmox-import-auth";
|
||||
|
||||
describe("resolveProxmoxImportAuth", () => {
|
||||
it("uses credential auth when a credential is available", () => {
|
||||
expect(resolveProxmoxImportAuth(undefined, 42)).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 42,
|
||||
overrideCredentialUsername: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not import password auth without a password secret", () => {
|
||||
expect(resolveProxmoxImportAuth("password", null)).toEqual({
|
||||
authType: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not import key auth without a private key secret", () => {
|
||||
expect(resolveProxmoxImportAuth("key", undefined)).toEqual({
|
||||
authType: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps secretless auth types", () => {
|
||||
expect(resolveProxmoxImportAuth("opkssh", null)).toEqual({
|
||||
authType: "opkssh",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
const SECRET_BACKED_AUTH_TYPES = new Set(["password", "key"]);
|
||||
const SECRETLESS_AUTH_TYPES = new Set(["none", "opkssh", "tailscale", "vault"]);
|
||||
|
||||
export type ProxmoxImportAuth = {
|
||||
authType: string;
|
||||
credentialId?: number;
|
||||
overrideCredentialUsername?: boolean;
|
||||
};
|
||||
|
||||
export function resolveProxmoxImportAuth(
|
||||
defaultAuthType: string | undefined,
|
||||
credentialId: number | null | undefined,
|
||||
): ProxmoxImportAuth {
|
||||
if (defaultAuthType === "credential" || (!defaultAuthType && credentialId)) {
|
||||
return credentialId
|
||||
? {
|
||||
authType: "credential",
|
||||
credentialId,
|
||||
overrideCredentialUsername: true,
|
||||
}
|
||||
: { authType: "none" };
|
||||
}
|
||||
|
||||
if (defaultAuthType && SECRETLESS_AUTH_TYPES.has(defaultAuthType)) {
|
||||
return { authType: defaultAuthType };
|
||||
}
|
||||
|
||||
if (defaultAuthType && !SECRET_BACKED_AUTH_TYPES.has(defaultAuthType)) {
|
||||
return { authType: defaultAuthType };
|
||||
}
|
||||
|
||||
return { authType: "none" };
|
||||
}
|
||||
+326
-187
@@ -5,6 +5,7 @@ import { Card } from "@/components/card";
|
||||
import { Separator } from "@/components/separator";
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
Database,
|
||||
ExternalLink,
|
||||
GripHorizontal,
|
||||
@@ -43,76 +44,37 @@ import {
|
||||
createServiceLink,
|
||||
deleteServiceLink,
|
||||
} from "@/main-axios";
|
||||
import type {
|
||||
RecentActivityItem,
|
||||
SSHHostWithStatus,
|
||||
ServiceLink,
|
||||
} from "@/main-axios";
|
||||
import type { RecentActivityItem, ServiceLink } from "@/main-axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
|
||||
import { HomepagePreviewCard } from "@/dashboard/cards/HomepagePreviewCard";
|
||||
import { HomepageCanvas } from "@/features/homepage/HomepageCanvas";
|
||||
|
||||
// Side-effect imports so homepage widgets register themselves
|
||||
import "@/features/homepage/widgets/ServiceLinkWidget";
|
||||
import "@/features/homepage/widgets/ClockWidget";
|
||||
import "@/features/homepage/widgets/NotesWidget";
|
||||
import "@/features/homepage/widgets/BookmarkListWidget";
|
||||
import "@/features/homepage/widgets/HostStatusWidget";
|
||||
import "@/features/homepage/widgets/FolderWidget";
|
||||
import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
|
||||
function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
return {
|
||||
id: String(h.id),
|
||||
name: h.name,
|
||||
username: h.username,
|
||||
ip: h.ip,
|
||||
port: h.port,
|
||||
folder: h.folder ?? "",
|
||||
online: h.status === "online",
|
||||
cpu: 0,
|
||||
ram: 0,
|
||||
lastAccess: "",
|
||||
tags: h.tags ?? [],
|
||||
authType: h.authType,
|
||||
password: h.password,
|
||||
key: typeof h.key === "string" ? h.key : undefined,
|
||||
keyPassword: h.keyPassword,
|
||||
keyType: h.keyType,
|
||||
credentialId: h.credentialId != null ? String(h.credentialId) : undefined,
|
||||
notes: h.notes,
|
||||
pin: h.pin ?? false,
|
||||
macAddress: h.macAddress,
|
||||
enableTerminal: h.enableTerminal ?? true,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? true,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableSsh: h.connectionType === "ssh" || !h.connectionType,
|
||||
enableRdp: h.connectionType === "rdp",
|
||||
enableVnc: h.connectionType === "vnc",
|
||||
enableTelnet: h.connectionType === "telnet",
|
||||
sshPort: h.port,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
telnetPort: 23,
|
||||
quickActions: (h.quickActions ?? []).map((a) => ({
|
||||
name: a.name,
|
||||
snippetId: String(a.snippetId),
|
||||
})),
|
||||
jumpHosts: (h.jumpHosts ?? []).map((j) => ({
|
||||
hostId: String(j.hostId),
|
||||
})),
|
||||
serverTunnels: [],
|
||||
defaultPath: h.defaultPath,
|
||||
terminalConfig: h.terminalConfig as Host["terminalConfig"],
|
||||
useSocks5: h.useSocks5,
|
||||
socks5Host: h.socks5Host,
|
||||
socks5Port: h.socks5Port,
|
||||
socks5Username: h.socks5Username,
|
||||
socks5Password: h.socks5Password,
|
||||
socks5ProxyChain: h.socks5ProxyChain ?? [],
|
||||
};
|
||||
}
|
||||
import { useServerStatus } from "@/lib/ServerStatusContext";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
|
||||
import {
|
||||
isValidServiceLinkUrl,
|
||||
normalizeServiceLinkUrl,
|
||||
} from "@/lib/service-link-url";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type PanelId = "main" | "side";
|
||||
|
||||
type CardSlot = {
|
||||
key: string;
|
||||
id: DashboardCardId;
|
||||
panel: PanelId;
|
||||
order: number;
|
||||
@@ -120,6 +82,7 @@ type CardSlot = {
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
key: string;
|
||||
id: DashboardCardId;
|
||||
sourcePanel: PanelId;
|
||||
sourceOrder: number;
|
||||
@@ -128,11 +91,35 @@ type DragState = {
|
||||
// ─── Default layout ───────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_SLOTS: CardSlot[] = [
|
||||
{ id: "stats_bar", panel: "main", order: 0, height: 96 },
|
||||
{ id: "counters_bar", panel: "main", order: 1, height: 48 },
|
||||
{ id: "quick_actions", panel: "main", order: 2, height: 160 },
|
||||
{ id: "host_status", panel: "main", order: 3, height: null },
|
||||
{ id: "recent_activity", panel: "side", order: 0, height: null },
|
||||
{ key: "stats_bar_0", id: "stats_bar", panel: "main", order: 0, height: 96 },
|
||||
{
|
||||
key: "counters_bar_0",
|
||||
id: "counters_bar",
|
||||
panel: "main",
|
||||
order: 1,
|
||||
height: 48,
|
||||
},
|
||||
{
|
||||
key: "quick_actions_0",
|
||||
id: "quick_actions",
|
||||
panel: "main",
|
||||
order: 2,
|
||||
height: 160,
|
||||
},
|
||||
{
|
||||
key: "host_status_0",
|
||||
id: "host_status",
|
||||
panel: "main",
|
||||
order: 3,
|
||||
height: null,
|
||||
},
|
||||
{
|
||||
key: "recent_activity_0",
|
||||
id: "recent_activity",
|
||||
panel: "side",
|
||||
order: 0,
|
||||
height: null,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Card components ──────────────────────────────────────────────────────────
|
||||
@@ -277,6 +264,25 @@ function QuickActionsCard({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pinnedHosts = hosts.filter((h) => h.pin);
|
||||
const getConnectionEndpoint = (host: Host) => {
|
||||
const type = getDefaultConnectionTab(host);
|
||||
const port =
|
||||
type === "rdp"
|
||||
? host.rdpPort
|
||||
: type === "vnc"
|
||||
? host.vncPort
|
||||
: type === "telnet"
|
||||
? host.telnetPort
|
||||
: host.sshPort;
|
||||
return `${host.ip}:${port}`;
|
||||
};
|
||||
const renderConnectionIcon = (host: Host) => {
|
||||
const type = getDefaultConnectionTab(host);
|
||||
if (type === "terminal" || type === "telnet") {
|
||||
return <Terminal className="size-3 text-accent-brand" />;
|
||||
}
|
||||
return <Server className="size-3 text-accent-brand" />;
|
||||
};
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
|
||||
@@ -330,18 +336,18 @@ function QuickActionsCard({
|
||||
{pinnedHosts.slice(0, 4).map((host) => (
|
||||
<button
|
||||
key={host.id}
|
||||
onClick={() => onOpenTab(host, "terminal")}
|
||||
onClick={() => onOpenTab(host, getDefaultConnectionTab(host))}
|
||||
className="group/btn flex items-center gap-2.5 px-4 py-2 hover:bg-muted transition-colors cursor-pointer border-b border-border last:border-b-0"
|
||||
>
|
||||
<div className="size-7 border border-border bg-muted flex items-center justify-center shrink-0 group-hover/btn:bg-accent-brand/20 group-hover/btn:border-accent-brand/40 transition-colors">
|
||||
<Terminal className="size-3 text-accent-brand" />
|
||||
{renderConnectionIcon(host)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start text-left min-w-0">
|
||||
<span className="text-xs font-semibold truncate w-full">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate w-full">
|
||||
{host.ip}:{host.sshPort}
|
||||
{getConnectionEndpoint(host)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -423,6 +429,7 @@ function HostStatusCard({
|
||||
hosts,
|
||||
hostMetrics,
|
||||
onOpenTab,
|
||||
statusLoading,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
hostMetrics: Map<
|
||||
@@ -430,6 +437,7 @@ function HostStatusCard({
|
||||
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||
>;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
statusLoading?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const statusScheme = useStatusColorScheme();
|
||||
@@ -467,7 +475,7 @@ function HostStatusCard({
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host.online, statusScheme, "dot")}`}
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host.online, statusScheme, "dot", statusLoading)}`}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -506,11 +514,13 @@ function HostStatusCard({
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(host.online, statusScheme, "badge")}`}
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(host.online, statusScheme, "badge", statusLoading)}`}
|
||||
>
|
||||
{host.online
|
||||
? t("dashboardTab.online")
|
||||
: t("dashboardTab.offline")}
|
||||
{statusLoading
|
||||
? t("dashboardTab.checking")
|
||||
: host.online
|
||||
? t("dashboardTab.online")
|
||||
: t("dashboardTab.offline")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -526,11 +536,13 @@ function RecentActivityCard({
|
||||
hosts,
|
||||
onOpenTab,
|
||||
onClear,
|
||||
statusLoading,
|
||||
}: {
|
||||
activity: RecentActivityItem[];
|
||||
hosts: Host[];
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
onClear: () => void;
|
||||
statusLoading?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const statusScheme = useStatusColorScheme();
|
||||
@@ -609,7 +621,7 @@ function RecentActivityCard({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host?.online ?? false, statusScheme, "dot")}`}
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host?.online ?? false, statusScheme, "dot", statusLoading)}`}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold truncate max-w-24">
|
||||
@@ -645,28 +657,29 @@ function ServiceLinksCard({
|
||||
const [label, setLabel] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [urlError, setUrlError] = useState(false);
|
||||
const [addError, setAddError] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const handleAdd = async () => {
|
||||
let valid = true;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
valid = false;
|
||||
}
|
||||
} catch {
|
||||
valid = false;
|
||||
}
|
||||
if (!valid) {
|
||||
const normalizedUrl = normalizeServiceLinkUrl(url);
|
||||
if (!isValidServiceLinkUrl(normalizedUrl)) {
|
||||
setUrlError(true);
|
||||
setAddError("");
|
||||
return;
|
||||
}
|
||||
setUrlError(false);
|
||||
setAddError("");
|
||||
setAdding(true);
|
||||
try {
|
||||
await onAdd(label.trim(), url.trim());
|
||||
await onAdd(label.trim(), normalizedUrl);
|
||||
setLabel("");
|
||||
setUrl("");
|
||||
} catch (error) {
|
||||
setAddError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("dashboardTab.serviceLinksAddFailed"),
|
||||
);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
@@ -728,6 +741,7 @@ function ServiceLinksCard({
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setUrlError(false);
|
||||
setAddError("");
|
||||
}}
|
||||
placeholder={t("dashboardTab.serviceLinksUrlPlaceholder")}
|
||||
className={`flex-[2] min-w-0 text-xs bg-transparent border px-2 py-1 focus:outline-none ${urlError ? "border-destructive" : "border-border focus:border-accent-brand/60"}`}
|
||||
@@ -746,6 +760,11 @@ function ServiceLinksCard({
|
||||
{t("dashboardTab.serviceLinksInvalidUrl")}
|
||||
</div>
|
||||
)}
|
||||
{addError && (
|
||||
<div className="px-4 pb-2 text-[10px] text-destructive shrink-0">
|
||||
{addError}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -777,6 +796,7 @@ function CardItem({
|
||||
serviceLinks,
|
||||
onAddServiceLink,
|
||||
onDeleteServiceLink,
|
||||
statusLoading,
|
||||
}: {
|
||||
slot: CardSlot;
|
||||
editMode: boolean;
|
||||
@@ -785,7 +805,7 @@ function CardItem({
|
||||
onDrop: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onRemove: () => void;
|
||||
onHeightChange: (id: DashboardCardId, h: number) => void;
|
||||
onHeightChange: (key: string, h: number) => void;
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
hosts: Host[];
|
||||
@@ -805,6 +825,7 @@ function CardItem({
|
||||
serviceLinks: ServiceLink[];
|
||||
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||
statusLoading?: boolean;
|
||||
}) {
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -815,7 +836,7 @@ function CardItem({
|
||||
const startY = e.clientY;
|
||||
const startH = cardRef.current?.getBoundingClientRect().height ?? 100;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
onHeightChange(slot.id, Math.max(50, startH + (ev.clientY - startY)));
|
||||
onHeightChange(slot.key, Math.max(50, startH + (ev.clientY - startY)));
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
@@ -824,7 +845,7 @@ function CardItem({
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[slot.id, onHeightChange],
|
||||
[slot.key, onHeightChange],
|
||||
);
|
||||
|
||||
const isFlex = slot.height === null;
|
||||
@@ -886,6 +907,7 @@ function CardItem({
|
||||
hostMetrics={hostMetrics}
|
||||
onOpenTab={onOpenTab}
|
||||
isAdmin={isAdmin}
|
||||
statusLoading={statusLoading}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "recent_activity" && (
|
||||
@@ -894,6 +916,7 @@ function CardItem({
|
||||
hosts={hosts}
|
||||
onOpenTab={onOpenTab}
|
||||
onClear={onClearActivity}
|
||||
statusLoading={statusLoading}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "network_graph" && (
|
||||
@@ -909,6 +932,11 @@ function CardItem({
|
||||
onDelete={onDeleteServiceLink}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "homepage_preview" && (
|
||||
<HomepagePreviewCard
|
||||
onOpenFullscreen={() => onOpenSingletonTab("homepage")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{editMode && !isFlex && (
|
||||
<div
|
||||
@@ -999,9 +1027,9 @@ type PanelColumnProps = {
|
||||
onDragStart: (slot: CardSlot) => void;
|
||||
onDrop: (targetPanel: PanelId, targetOrder: number) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onRemove: (id: DashboardCardId) => void;
|
||||
onRemove: (key: string) => void;
|
||||
onAdd: (id: DashboardCardId, panel: PanelId) => void;
|
||||
onHeightChange: (id: DashboardCardId, h: number) => void;
|
||||
onHeightChange: (key: string, h: number) => void;
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
hosts: Host[];
|
||||
@@ -1022,6 +1050,7 @@ type PanelColumnProps = {
|
||||
serviceLinks: ServiceLink[];
|
||||
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||
statusLoading: boolean;
|
||||
};
|
||||
|
||||
function PanelColumn({
|
||||
@@ -1052,6 +1081,7 @@ function PanelColumn({
|
||||
serviceLinks,
|
||||
onAddServiceLink,
|
||||
onDeleteServiceLink,
|
||||
statusLoading,
|
||||
}: PanelColumnProps) {
|
||||
const { t } = useTranslation();
|
||||
const sorted = [...slots].sort((a, b) => a.order - b.order);
|
||||
@@ -1068,7 +1098,7 @@ function PanelColumn({
|
||||
/>
|
||||
{sorted.map((slot, idx) => (
|
||||
<div
|
||||
key={slot.id}
|
||||
key={slot.key}
|
||||
className={`flex flex-col min-h-0 ${slot.height === null ? "flex-1" : "shrink-0"}`}
|
||||
>
|
||||
{idx > 0 && (
|
||||
@@ -1085,11 +1115,11 @@ function PanelColumn({
|
||||
<CardItem
|
||||
slot={slot}
|
||||
editMode={editMode}
|
||||
isDragging={dragState?.id === slot.id}
|
||||
isDragging={dragState?.key === slot.key}
|
||||
onDragStart={() => onDragStart(slot)}
|
||||
onDrop={() => onDrop(slot.panel, slot.order)}
|
||||
onDragOver={onDragOver}
|
||||
onRemove={() => onRemove(slot.id)}
|
||||
onRemove={() => onRemove(slot.key)}
|
||||
onHeightChange={onHeightChange}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
onOpenTab={onOpenTab}
|
||||
@@ -1107,6 +1137,7 @@ function PanelColumn({
|
||||
serviceLinks={serviceLinks}
|
||||
onAddServiceLink={onAddServiceLink}
|
||||
onDeleteServiceLink={onDeleteServiceLink}
|
||||
statusLoading={statusLoading}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -1162,17 +1193,52 @@ export function DashboardTab({
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { initialLoadComplete } = useServerStatus();
|
||||
const statusLoading = !initialLoadComplete;
|
||||
|
||||
const [slots, setSlots] = useState<CardSlot[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("dashboardTab.slots");
|
||||
if (saved) return JSON.parse(saved) as CardSlot[];
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as CardSlot[];
|
||||
return parsed.map((s, i) => ({ key: s.key ?? `${s.id}_${i}`, ...s }));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return DEFAULT_SLOTS;
|
||||
});
|
||||
|
||||
const [homepageLinkCopied, setHomepageLinkCopied] = useState(false);
|
||||
|
||||
const handleCopyHomepageLink = () => {
|
||||
navigator.clipboard
|
||||
.writeText(`${window.location.origin}?view=homepage`)
|
||||
.catch(() => {});
|
||||
setHomepageLinkCopied(true);
|
||||
setTimeout(() => setHomepageLinkCopied(false), 1500);
|
||||
};
|
||||
|
||||
const [dashboardView, setDashboardView] = useState<"dashboard" | "homepage">(
|
||||
() => {
|
||||
try {
|
||||
return (localStorage.getItem("dashboardView") ?? "dashboard") as
|
||||
| "dashboard"
|
||||
| "homepage";
|
||||
} catch {
|
||||
return "dashboard";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("dashboardView", dashboardView);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [dashboardView]);
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [dragState, setDragState] = useState<DragState>(null);
|
||||
|
||||
@@ -1401,6 +1467,7 @@ export function DashboardTab({
|
||||
recent_activity: t("dashboard.recentActivity"),
|
||||
network_graph: t("dashboard.networkGraph"),
|
||||
service_links: t("dashboard.serviceLinks"),
|
||||
homepage_preview: t("dashboard.homepagePreview"),
|
||||
};
|
||||
|
||||
const onColumnDividerMouseDown = useCallback(
|
||||
@@ -1430,6 +1497,7 @@ export function DashboardTab({
|
||||
|
||||
const handleDragStart = (slot: CardSlot) =>
|
||||
setDragState({
|
||||
key: slot.key,
|
||||
id: slot.id,
|
||||
sourcePanel: slot.panel,
|
||||
sourceOrder: slot.order,
|
||||
@@ -1438,7 +1506,7 @@ export function DashboardTab({
|
||||
const handleDrop = (targetPanel: PanelId, targetOrder: number) => {
|
||||
if (!dragState) return;
|
||||
setSlots((prev) => {
|
||||
const without = prev.filter((s) => s.id !== dragState.id);
|
||||
const without = prev.filter((s) => s.key !== dragState.key);
|
||||
const panelSlots = without
|
||||
.filter((s) => s.panel === targetPanel)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
@@ -1448,10 +1516,11 @@ export function DashboardTab({
|
||||
const newPanelSlots = [
|
||||
...panelSlots.slice(0, insertAt),
|
||||
{
|
||||
key: dragState.key,
|
||||
id: dragState.id,
|
||||
panel: targetPanel,
|
||||
order: 0,
|
||||
height: prev.find((s) => s.id === dragState.id)?.height ?? null,
|
||||
height: prev.find((s) => s.key === dragState.key)?.height ?? null,
|
||||
},
|
||||
...panelSlots.slice(insertAt),
|
||||
].map((s, i) => ({ ...s, order: i }));
|
||||
@@ -1459,8 +1528,8 @@ export function DashboardTab({
|
||||
});
|
||||
setDragState(null);
|
||||
};
|
||||
const handleRemove = (id: DashboardCardId) =>
|
||||
setSlots((prev) => prev.filter((s) => s.id !== id));
|
||||
const handleRemove = (key: string) =>
|
||||
setSlots((prev) => prev.filter((s) => s.key !== key));
|
||||
const handleAdd = (id: DashboardCardId, panel: PanelId) => {
|
||||
setSlots((prev) => {
|
||||
const panelSlots = prev.filter((s) => s.panel === panel);
|
||||
@@ -1476,12 +1545,16 @@ export function DashboardTab({
|
||||
: id === "service_links"
|
||||
? 200
|
||||
: 150;
|
||||
return [...prev, { id, panel, order: maxOrder, height: defaultHeight }];
|
||||
const key = `${id}_${Date.now()}`;
|
||||
return [
|
||||
...prev,
|
||||
{ key, id, panel, order: maxOrder, height: defaultHeight },
|
||||
];
|
||||
});
|
||||
};
|
||||
const handleHeightChange = (id: DashboardCardId, h: number) =>
|
||||
const handleHeightChange = (key: string, h: number) =>
|
||||
setSlots((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, height: h } : s)),
|
||||
prev.map((s) => (s.key === key ? { ...s, height: h } : s)),
|
||||
);
|
||||
const handleReset = () => {
|
||||
setSlots(DEFAULT_SLOTS);
|
||||
@@ -1513,6 +1586,7 @@ export function DashboardTab({
|
||||
serviceLinks,
|
||||
onAddServiceLink: handleAddServiceLink,
|
||||
onDeleteServiceLink: handleDeleteServiceLink,
|
||||
statusLoading,
|
||||
};
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
@@ -1637,6 +1711,7 @@ export function DashboardTab({
|
||||
hostMetrics={hostMetrics}
|
||||
onOpenTab={onOpenTab}
|
||||
isAdmin={isAdmin}
|
||||
statusLoading={statusLoading}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "recent_activity" && (
|
||||
@@ -1645,6 +1720,7 @@ export function DashboardTab({
|
||||
hosts={hosts}
|
||||
onOpenTab={onOpenTab}
|
||||
onClear={handleClearActivity}
|
||||
statusLoading={statusLoading}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "network_graph" && (
|
||||
@@ -1670,14 +1746,29 @@ export function DashboardTab({
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full min-h-0 overflow-hidden">
|
||||
<Card className="flex-row items-center justify-between px-5 py-3 shrink-0 mx-5 mt-5 gap-0">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold leading-tight">
|
||||
{t("dashboard.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">{todayLabel}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-0 bg-muted/40 border border-border p-0.5">
|
||||
<button
|
||||
onClick={() => setDashboardView("dashboard")}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${dashboardView === "dashboard" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t("dashboard.title")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDashboardView("homepage")}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${dashboardView === "homepage" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t("nav.homepage")}
|
||||
</button>
|
||||
</div>
|
||||
{dashboardView === "dashboard" && (
|
||||
<p className="text-xs text-muted-foreground hidden sm:block">
|
||||
{todayLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="hidden sm:flex items-center gap-2 mr-2 bg-muted/50 px-2.5 py-1 rounded-sm border border-border">
|
||||
<div className="hidden sm:flex items-center gap-2 mr-2 bg-muted/50 px-2.5 py-1 rounded-none border border-border">
|
||||
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
{t("dashboardTab.commandPalette")}
|
||||
</span>
|
||||
@@ -1757,95 +1848,143 @@ export function DashboardTab({
|
||||
{t("dashboard.donate")}
|
||||
</a>
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||
{editMode ? (
|
||||
{dashboardView === "dashboard" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t("dashboard.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs bg-accent-brand hover:bg-accent-brand/90 text-white"
|
||||
onClick={() => setEditMode(false)}
|
||||
>
|
||||
{t("dashboardTab.done")}
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||
{editMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t("dashboard.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs bg-accent-brand hover:bg-accent-brand/90 text-white"
|
||||
onClick={() => setEditMode(false)}
|
||||
>
|
||||
{t("dashboardTab.done")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditMode(true)}
|
||||
title={t("dashboard.customizeLayout")}
|
||||
>
|
||||
<LayoutDashboard className="size-4 text-accent-brand" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditMode(true)}
|
||||
title={t("dashboard.customizeLayout")}
|
||||
>
|
||||
<LayoutDashboard className="size-4 text-accent-brand" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{editMode && (
|
||||
<div className="mx-5 mt-4 px-4 py-2 border border-dashed border-accent-brand/40 bg-accent-brand/5 shrink-0 flex items-center gap-2">
|
||||
<LayoutDashboard className="size-3.5 text-accent-brand shrink-0" />
|
||||
<span className="text-xs text-accent-brand font-semibold">
|
||||
{t("dashboardTab.editModeInstructions")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className="flex flex-row flex-1 min-h-0 px-5 pb-5 pt-4 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="flex flex-col min-h-0"
|
||||
style={{ width: hasSide || editMode ? `${mainWidthPct}%` : "100%" }}
|
||||
>
|
||||
<PanelColumn
|
||||
panel="main"
|
||||
slots={mainSlots}
|
||||
editMode={editMode}
|
||||
dragState={dragState}
|
||||
onDragStart={handleDragStart}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onRemove={handleRemove}
|
||||
onAdd={handleAdd}
|
||||
onHeightChange={handleHeightChange}
|
||||
{...columnProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(hasSide || editMode) &&
|
||||
(editMode ? (
|
||||
<ColumnDivider onMouseDown={onColumnDividerMouseDown} />
|
||||
) : (
|
||||
<div className="w-4 shrink-0" />
|
||||
))}
|
||||
|
||||
{(hasSide || editMode) && (
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<PanelColumn
|
||||
panel="side"
|
||||
slots={sideSlots}
|
||||
editMode={editMode}
|
||||
dragState={dragState}
|
||||
onDragStart={handleDragStart}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onRemove={handleRemove}
|
||||
onAdd={handleAdd}
|
||||
onHeightChange={handleHeightChange}
|
||||
{...columnProps}
|
||||
/>
|
||||
{dashboardView === "homepage" ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden mx-5 mb-5 mt-4 border border-border flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border shrink-0 bg-muted/20">
|
||||
<span className="text-[10px] text-muted-foreground/50 uppercase tracking-widest font-semibold">
|
||||
{t("nav.homepage")}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
onClick={handleCopyHomepageLink}
|
||||
>
|
||||
{homepageLinkCopied ? (
|
||||
<>
|
||||
<Check size={10} className="text-accent-brand" />
|
||||
<span className="text-accent-brand">
|
||||
{t("homepage.linkCopied")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link size={10} />
|
||||
{t("homepage.copyLink")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
onClick={() => onOpenSingletonTab("homepage")}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
{t("homepage.openFullView")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<HomepageCanvas />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{editMode && (
|
||||
<div className="mx-5 mt-4 px-4 py-2 border border-dashed border-accent-brand/40 bg-accent-brand/5 shrink-0 flex items-center gap-2">
|
||||
<LayoutDashboard className="size-3.5 text-accent-brand shrink-0" />
|
||||
<span className="text-xs text-accent-brand font-semibold">
|
||||
{t("dashboardTab.editModeInstructions")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className="flex flex-row flex-1 min-h-0 px-5 pb-5 pt-4 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="flex flex-col min-h-0"
|
||||
style={{
|
||||
width: hasSide || editMode ? `${mainWidthPct}%` : "100%",
|
||||
}}
|
||||
>
|
||||
<PanelColumn
|
||||
panel="main"
|
||||
slots={mainSlots}
|
||||
editMode={editMode}
|
||||
dragState={dragState}
|
||||
onDragStart={handleDragStart}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onRemove={handleRemove}
|
||||
onAdd={handleAdd}
|
||||
onHeightChange={handleHeightChange}
|
||||
{...columnProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(hasSide || editMode) &&
|
||||
(editMode ? (
|
||||
<ColumnDivider onMouseDown={onColumnDividerMouseDown} />
|
||||
) : (
|
||||
<div className="w-4 shrink-0" />
|
||||
))}
|
||||
|
||||
{(hasSide || editMode) && (
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<PanelColumn
|
||||
panel="side"
|
||||
slots={sideSlots}
|
||||
editMode={editMode}
|
||||
dragState={dragState}
|
||||
onDragStart={handleDragStart}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onRemove={handleRemove}
|
||||
onAdd={handleAdd}
|
||||
onHeightChange={handleHeightChange}
|
||||
{...columnProps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Card } from "@/components/card";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { HomepageCanvas } from "@/features/homepage/HomepageCanvas";
|
||||
|
||||
interface HomepagePreviewCardProps {
|
||||
onOpenFullscreen: () => void;
|
||||
}
|
||||
|
||||
export function HomepagePreviewCard({
|
||||
onOpenFullscreen,
|
||||
}: HomepagePreviewCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="relative overflow-hidden w-full h-full flex flex-col p-0 gap-0">
|
||||
{/* Card header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border">
|
||||
<LayoutGrid className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("homepage.previewTitle")}
|
||||
</span>
|
||||
<button
|
||||
className="ml-auto text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={onOpenFullscreen}
|
||||
>
|
||||
{t("homepage.openFullView")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Live pannable preview — no pointer-events blocking so panning works */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<HomepageCanvas isReadOnly={true} fitOnLoad={true} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -554,7 +554,7 @@ export function NetworkGraphCard({
|
||||
|
||||
const hideMenu = () => setContextMenu((p) => ({ ...p, visible: false }));
|
||||
|
||||
const fireOpen = (hostId: string, type: string) => {
|
||||
const fireOpen = (hostId: string, type?: string) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:open-tab", { detail: { hostId, type } }),
|
||||
);
|
||||
@@ -571,7 +571,7 @@ export function NetworkGraphCard({
|
||||
setShowNodeDetail(true);
|
||||
}
|
||||
} else if (action === "connect") {
|
||||
fireOpen(targetId, "terminal");
|
||||
fireOpen(targetId);
|
||||
} else if (action === "move") {
|
||||
setSelectedNodeId(targetId);
|
||||
const node = cyRef.current.$id(targetId);
|
||||
|
||||
@@ -79,11 +79,14 @@ import type {
|
||||
} from "./file-manager-types.ts";
|
||||
import { formatFileSize } from "./file-manager-utils.ts";
|
||||
|
||||
const LARGE_FILE_WARNING_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
function FileManagerContent({
|
||||
initialHost,
|
||||
initialFilePath,
|
||||
initialPath,
|
||||
onClose,
|
||||
onOpenTerminalTab,
|
||||
}: FileManagerProps) {
|
||||
const { openWindow } = useWindowManager();
|
||||
const { t } = useTranslation();
|
||||
@@ -165,6 +168,8 @@ function FileManagerContent({
|
||||
const [clipboard, setClipboard] = useState<{
|
||||
files: FileItem[];
|
||||
operation: "copy" | "cut";
|
||||
sourceHostId: number | null;
|
||||
sourceSessionId: string | null;
|
||||
} | null>(null);
|
||||
|
||||
interface UndoAction {
|
||||
@@ -797,6 +802,23 @@ function FileManagerContent({
|
||||
return () => window.removeEventListener("file-manager:refresh", handler);
|
||||
}, [currentHost?.id, handleRefreshDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (
|
||||
event as CustomEvent<{
|
||||
files: FileItem[];
|
||||
operation: "copy" | "cut";
|
||||
sourceHostId: number | null;
|
||||
sourceSessionId: string | null;
|
||||
}>
|
||||
).detail;
|
||||
if (!detail) return;
|
||||
setClipboard(detail);
|
||||
};
|
||||
window.addEventListener("file-manager:clipboard", handler);
|
||||
return () => window.removeEventListener("file-manager:clipboard", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
@@ -844,12 +866,15 @@ function FileManagerContent({
|
||||
files.push({ file, relativePath: path });
|
||||
} else if (entry.isDirectory) {
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
const dirEntries = await new Promise<FileSystemEntry[]>(
|
||||
(resolve, reject) => reader.readEntries(resolve, reject),
|
||||
);
|
||||
for (const child of dirEntries) {
|
||||
await readEntry(child, `${path}/${child.name}`);
|
||||
}
|
||||
let batch: FileSystemEntry[];
|
||||
do {
|
||||
batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
|
||||
reader.readEntries(resolve, reject),
|
||||
);
|
||||
for (const child of batch) {
|
||||
await readEntry(child, `${path}/${child.name}`);
|
||||
}
|
||||
} while (batch.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,6 +972,22 @@ function FileManagerContent({
|
||||
{ duration: Infinity },
|
||||
);
|
||||
|
||||
const updateProgress = (p: {
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
bytesSent: number;
|
||||
totalBytes: number;
|
||||
}) => {
|
||||
const percent = Math.min(
|
||||
100,
|
||||
Math.round((p.bytesSent / p.totalBytes) * 100),
|
||||
);
|
||||
toast.loading(
|
||||
`Uploading ${file.name} — ${percent}% (chunk ${p.chunkIndex + 1}/${p.totalChunks})`,
|
||||
{ id: progressToast, duration: Infinity },
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
@@ -957,6 +998,7 @@ function FileManagerContent({
|
||||
file,
|
||||
currentHost?.id,
|
||||
undefined,
|
||||
updateProgress,
|
||||
);
|
||||
|
||||
toast.dismiss(progressToast);
|
||||
@@ -1281,55 +1323,81 @@ function FileManagerContent({
|
||||
}
|
||||
};
|
||||
|
||||
function openFileWindow(file: FileItem, sessionId: string) {
|
||||
const windowCount = Date.now() % 10;
|
||||
const baseOffsetX = 120 + windowCount * 30;
|
||||
const baseOffsetY = 120 + windowCount * 30;
|
||||
|
||||
const maxOffsetX = Math.max(0, window.innerWidth - 800 - 100);
|
||||
const maxOffsetY = Math.max(0, window.innerHeight - 600 - 100);
|
||||
|
||||
const offsetX = Math.min(baseOffsetX, maxOffsetX);
|
||||
const offsetY = Math.min(baseOffsetY, maxOffsetY);
|
||||
|
||||
const createWindowComponent = (windowId: string) => (
|
||||
<FileWindow
|
||||
windowId={windowId}
|
||||
file={file}
|
||||
sshSessionId={sessionId}
|
||||
sshHost={currentHost}
|
||||
initialX={offsetX}
|
||||
initialY={offsetY}
|
||||
onFileNotFound={handleFileNotFound}
|
||||
/>
|
||||
);
|
||||
|
||||
openWindow({
|
||||
title: file.name,
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
width: 800,
|
||||
height: 600,
|
||||
isMaximized: false,
|
||||
isMinimized: false,
|
||||
component: createWindowComponent,
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmLargeFileOpen(file: FileItem) {
|
||||
if (!file.size || file.size <= LARGE_FILE_WARNING_SIZE) return true;
|
||||
|
||||
return confirmWithToast(
|
||||
{
|
||||
title: t("fileManager.largeFileWarning"),
|
||||
description: t("fileManager.largeFileWarningDesc", {
|
||||
size: formatFileSize(file.size),
|
||||
}),
|
||||
confirmText: t("fileManager.confirm"),
|
||||
cancelText: t("common.cancel"),
|
||||
},
|
||||
undefined,
|
||||
"default",
|
||||
t("common.cancel"),
|
||||
{ duration: 12000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function handleFileOpen(file: FileItem) {
|
||||
if (file.type === "directory") {
|
||||
if (sshSessionId) setIsLoading(true);
|
||||
setCurrentPath(file.path);
|
||||
} else if (file.type === "link") {
|
||||
await handleSymlinkClick(file);
|
||||
} else {
|
||||
if (!sshSessionId) {
|
||||
toast.error(t("fileManager.noSSHConnection"));
|
||||
return;
|
||||
}
|
||||
|
||||
await recordRecentFile(file);
|
||||
|
||||
const windowCount = Date.now() % 10;
|
||||
const baseOffsetX = 120 + windowCount * 30;
|
||||
const baseOffsetY = 120 + windowCount * 30;
|
||||
|
||||
const maxOffsetX = Math.max(0, window.innerWidth - 800 - 100);
|
||||
const maxOffsetY = Math.max(0, window.innerHeight - 600 - 100);
|
||||
|
||||
const offsetX = Math.min(baseOffsetX, maxOffsetX);
|
||||
const offsetY = Math.min(baseOffsetY, maxOffsetY);
|
||||
|
||||
const windowTitle = file.name;
|
||||
|
||||
const createWindowComponent = (windowId: string) => (
|
||||
<FileWindow
|
||||
windowId={windowId}
|
||||
file={file}
|
||||
sshSessionId={sshSessionId}
|
||||
sshHost={currentHost}
|
||||
initialX={offsetX}
|
||||
initialY={offsetY}
|
||||
onFileNotFound={handleFileNotFound}
|
||||
/>
|
||||
);
|
||||
|
||||
openWindow({
|
||||
title: windowTitle,
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
width: 800,
|
||||
height: 600,
|
||||
isMaximized: false,
|
||||
isMinimized: false,
|
||||
component: createWindowComponent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type === "link") {
|
||||
await handleSymlinkClick(file);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sshSessionId) {
|
||||
toast.error(t("fileManager.noSSHConnection"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await confirmLargeFileOpen(file))) return;
|
||||
|
||||
await recordRecentFile(file);
|
||||
openFileWindow(file, sshSessionId);
|
||||
}
|
||||
|
||||
function handleContextMenu(event: React.MouseEvent, file?: FileItem) {
|
||||
@@ -1365,14 +1433,32 @@ function FileManagerContent({
|
||||
}
|
||||
|
||||
function handleCopyFiles(files: FileItem[]) {
|
||||
setClipboard({ files, operation: "copy" });
|
||||
const entry = {
|
||||
files,
|
||||
operation: "copy" as const,
|
||||
sourceHostId: currentHost?.id ?? null,
|
||||
sourceSessionId: sshSessionId,
|
||||
};
|
||||
setClipboard(entry);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("file-manager:clipboard", { detail: entry }),
|
||||
);
|
||||
toast.success(
|
||||
t("fileManager.filesCopiedToClipboard", { count: files.length }),
|
||||
);
|
||||
}
|
||||
|
||||
function handleCutFiles(files: FileItem[]) {
|
||||
setClipboard({ files, operation: "cut" });
|
||||
const entry = {
|
||||
files,
|
||||
operation: "cut" as const,
|
||||
sourceHostId: currentHost?.id ?? null,
|
||||
sourceSessionId: sshSessionId,
|
||||
};
|
||||
setClipboard(entry);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("file-manager:clipboard", { detail: entry }),
|
||||
);
|
||||
toast.success(
|
||||
t("fileManager.filesCutToClipboard", { count: files.length }),
|
||||
);
|
||||
@@ -1413,9 +1499,64 @@ function FileManagerContent({
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCrossHostPaste() {
|
||||
if (!clipboard || !sshSessionId || !currentHost) return;
|
||||
|
||||
const { files, operation, sourceSessionId } = clipboard;
|
||||
if (!sourceSessionId) return;
|
||||
|
||||
const sourcePaths = files.map((f) => f.path);
|
||||
|
||||
try {
|
||||
const { transferId } = await transferToHost(
|
||||
sourceSessionId,
|
||||
sourcePaths,
|
||||
sshSessionId,
|
||||
currentPath,
|
||||
operation === "cut",
|
||||
"auto",
|
||||
2,
|
||||
);
|
||||
|
||||
const monitorHandle = beginTransferProgressMonitoring(transferId, t, {
|
||||
formatTransferMetrics,
|
||||
});
|
||||
if (!monitorHandle) return;
|
||||
|
||||
const finalStatus = await monitorHandle.waitForCompletion;
|
||||
|
||||
if (
|
||||
finalStatus.status !== "success" &&
|
||||
finalStatus.status !== "partial"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation === "cut") {
|
||||
setClipboard(null);
|
||||
}
|
||||
|
||||
handleRefreshDirectory();
|
||||
clearSelection();
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("transfer.transferError")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasteFiles() {
|
||||
if (!clipboard || !sshSessionId) return;
|
||||
|
||||
if (
|
||||
clipboard.sourceHostId !== null &&
|
||||
clipboard.sourceHostId !== currentHost?.id
|
||||
) {
|
||||
await handleCrossHostPaste();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
@@ -2455,6 +2596,7 @@ function FileManagerContent({
|
||||
initialPath={path}
|
||||
initialX={offsetX}
|
||||
initialY={offsetY}
|
||||
onPromoteToTab={onOpenTerminalTab}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2501,6 +2643,7 @@ function FileManagerContent({
|
||||
initialX={offsetX}
|
||||
initialY={offsetY}
|
||||
executeCommand={executeCmd}
|
||||
onPromoteToTab={onOpenTerminalTab}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2676,7 +2819,7 @@ function FileManagerContent({
|
||||
});
|
||||
break;
|
||||
case "modified":
|
||||
result = (a.modified || "").localeCompare(b.modified || "");
|
||||
result = (a.modifiedTimestamp || 0) - (b.modifiedTimestamp || 0);
|
||||
break;
|
||||
case "size":
|
||||
result = (a.size || 0) - (b.size || 0);
|
||||
@@ -2961,6 +3104,7 @@ function FileManagerInner({
|
||||
initialFilePath,
|
||||
initialPath,
|
||||
onClose,
|
||||
onOpenTerminalTab,
|
||||
}: FileManagerProps) {
|
||||
return (
|
||||
<WindowManager>
|
||||
@@ -2969,6 +3113,7 @@ function FileManagerInner({
|
||||
initialFilePath={initialFilePath}
|
||||
initialPath={initialPath}
|
||||
onClose={onClose}
|
||||
onOpenTerminalTab={onOpenTerminalTab}
|
||||
/>
|
||||
</WindowManager>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ interface DraggableWindowProps {
|
||||
zIndex?: number;
|
||||
onFocus?: () => void;
|
||||
targetSize?: { width: number; height: number };
|
||||
titleActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DraggableWindow({
|
||||
@@ -39,6 +40,7 @@ export function DraggableWindow({
|
||||
zIndex = 1000,
|
||||
onFocus,
|
||||
targetSize,
|
||||
titleActions,
|
||||
}: DraggableWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [position, setPosition] = useState({ x: initialX, y: initialY });
|
||||
@@ -284,12 +286,14 @@ export function DraggableWindow({
|
||||
{t("fileManager.editor")}
|
||||
</span>
|
||||
<div className="h-4 w-px bg-border/50 shrink-0" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground truncate">
|
||||
<span className="text-[10px] font-bold tracking-tight text-muted-foreground truncate">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
{titleActions}
|
||||
|
||||
{onMinimize && (
|
||||
<button
|
||||
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Save,
|
||||
RotateCcw,
|
||||
Keyboard,
|
||||
ExternalLink,
|
||||
Settings,
|
||||
Search,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
@@ -92,6 +94,8 @@ interface FileViewerProps {
|
||||
onSave?: (content: string) => void;
|
||||
onRevert?: () => void;
|
||||
onDownload?: () => void;
|
||||
onOpenExternal?: () => void;
|
||||
onChooseExternalEditor?: () => void;
|
||||
onMediaDimensionsChange?: (dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -273,6 +277,8 @@ export function FileViewer({
|
||||
onSave,
|
||||
onRevert,
|
||||
onDownload,
|
||||
onOpenExternal,
|
||||
onChooseExternalEditor,
|
||||
onMediaDimensionsChange,
|
||||
}: FileViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -511,6 +517,30 @@ export function FileViewer({
|
||||
{t("fileManager.download")}
|
||||
</Button>
|
||||
)}
|
||||
{isEditable && onOpenExternal && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpenExternal}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("fileManager.openExternalEditor")}
|
||||
</Button>
|
||||
{onChooseExternalEditor && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onChooseExternalEditor}
|
||||
className="h-8 w-8 p-0"
|
||||
title={t("fileManager.chooseExternalEditor")}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,10 +87,26 @@ export function FileWindow({
|
||||
const [mediaDimensions, setMediaDimensions] = useState<
|
||||
{ width: number; height: number } | undefined
|
||||
>();
|
||||
const [externalEditorPath, setExternalEditorPath] = useState<string>("");
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const externalEditorRef = useRef<{
|
||||
editId: string;
|
||||
unsubscribe?: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI?.isElectron) return;
|
||||
|
||||
window.electronAPI
|
||||
.getSetting?.("fileManager.externalEditorPath")
|
||||
.then((value) => {
|
||||
if (typeof value === "string") setExternalEditorPath(value);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const ensureSSHConnection = async () => {
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
@@ -328,6 +344,93 @@ export function FileWindow({
|
||||
}
|
||||
};
|
||||
|
||||
const closeExternalEditor = async () => {
|
||||
const session = externalEditorRef.current;
|
||||
if (!session) return;
|
||||
|
||||
session.unsubscribe?.();
|
||||
externalEditorRef.current = null;
|
||||
|
||||
try {
|
||||
await window.electronAPI?.closeExternalEditor?.(session.editId);
|
||||
} catch (error) {
|
||||
console.error("Failed to close external editor session:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenExternalEditor = async () => {
|
||||
if (!window.electronAPI?.isElectron) {
|
||||
toast.error(t("fileManager.externalEditorDesktopOnly"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await closeExternalEditor();
|
||||
const result = await window.electronAPI.openExternalEditor({
|
||||
fileName: file.name,
|
||||
content: pendingContent || content,
|
||||
encoding: "utf8",
|
||||
editorPath: externalEditorPath || null,
|
||||
});
|
||||
|
||||
if (!result.success || !result.editId) {
|
||||
throw new Error(result.error || "Failed to open external editor");
|
||||
}
|
||||
|
||||
const unsubscribe = window.electronAPI.onExternalEditorSaved?.(
|
||||
(payload) => {
|
||||
if (payload.editId !== result.editId) return;
|
||||
void handleSave(payload.content);
|
||||
},
|
||||
);
|
||||
|
||||
externalEditorRef.current = {
|
||||
editId: result.editId,
|
||||
unsubscribe,
|
||||
};
|
||||
toast.success(t("fileManager.externalEditorOpened"));
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to open external editor:", error);
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.failedToOpenExternalEditor")}: ${
|
||||
err.message || t("fileManager.unknownError")
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChooseExternalEditor = async () => {
|
||||
if (!window.electronAPI?.isElectron) {
|
||||
toast.error(t("fileManager.externalEditorDesktopOnly"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.showOpenDialog({
|
||||
title: t("fileManager.chooseExternalEditor"),
|
||||
properties: ["openFile"],
|
||||
});
|
||||
const editorPath = result.filePaths?.[0];
|
||||
if (result.canceled || !editorPath) return;
|
||||
|
||||
await window.electronAPI.setSetting?.(
|
||||
"fileManager.externalEditorPath",
|
||||
editorPath,
|
||||
);
|
||||
setExternalEditorPath(editorPath);
|
||||
toast.success(t("fileManager.externalEditorSelected"));
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to select external editor:", error);
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.failedToSelectExternalEditor")}: ${
|
||||
err.message || t("fileManager.unknownError")
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setPendingContent(newContent);
|
||||
|
||||
@@ -354,6 +457,7 @@ export function FileWindow({
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
void closeExternalEditor();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -405,6 +509,7 @@ export function FileWindow({
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
void closeExternalEditor();
|
||||
closeWindow(windowId);
|
||||
};
|
||||
|
||||
@@ -454,6 +559,16 @@ export function FileWindow({
|
||||
onContentChange={handleContentChange}
|
||||
onSave={(newContent) => handleSave(newContent)}
|
||||
onDownload={handleDownload}
|
||||
onOpenExternal={
|
||||
window.electronAPI?.isElectron && isEditable
|
||||
? handleOpenExternalEditor
|
||||
: undefined
|
||||
}
|
||||
onChooseExternalEditor={
|
||||
window.electronAPI?.isElectron && isEditable
|
||||
? handleChooseExternalEditor
|
||||
: undefined
|
||||
}
|
||||
onMediaDimensionsChange={handleMediaDimensionsChange}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
|
||||
@@ -174,7 +174,7 @@ export function PermissionsDialog({
|
||||
<Lock className="size-4 text-accent-brand" />
|
||||
{t("fileManager.changePermissions")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground font-mono break-all">
|
||||
<DialogDescription className="text-[10px] font-bold tracking-tight text-muted-foreground font-mono break-all">
|
||||
{file.path}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useWindowManager } from "./WindowManager.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import type { SSHHost } from "@/types/index.ts";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
interface TerminalWindowProps {
|
||||
windowId: string;
|
||||
@@ -17,6 +18,7 @@ interface TerminalWindowProps {
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
executeCommand?: string;
|
||||
onPromoteToTab?: (path?: string) => void;
|
||||
}
|
||||
|
||||
export function TerminalWindow({
|
||||
@@ -26,6 +28,7 @@ export function TerminalWindow({
|
||||
initialX = 200,
|
||||
initialY = 150,
|
||||
executeCommand,
|
||||
onPromoteToTab,
|
||||
}: TerminalWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { closeWindow, maximizeWindow, focusWindow, windows } =
|
||||
@@ -78,6 +81,11 @@ export function TerminalWindow({
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handlePromoteToTab = () => {
|
||||
onPromoteToTab?.(initialPath);
|
||||
closeWindow(windowId);
|
||||
};
|
||||
|
||||
const terminalTitle = executeCommand
|
||||
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
|
||||
: initialPath
|
||||
@@ -103,6 +111,20 @@ export function TerminalWindow({
|
||||
onResize={handleResize}
|
||||
isMaximized={currentWindow.isMaximized}
|
||||
zIndex={currentWindow.zIndex}
|
||||
titleActions={
|
||||
onPromoteToTab ? (
|
||||
<button
|
||||
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePromoteToTab();
|
||||
}}
|
||||
title={t("common.openInNewTab")}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Terminal
|
||||
ref={terminalRef}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface FileManagerProps {
|
||||
initialFilePath?: string;
|
||||
initialPath?: string;
|
||||
onClose?: () => void;
|
||||
onOpenTerminalTab?: (path?: string) => void;
|
||||
}
|
||||
|
||||
export type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import {
|
||||
GuacamoleDisplay,
|
||||
type GuacamoleDisplayHandle,
|
||||
@@ -22,67 +28,71 @@ interface GuacamoleAppProps {
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
}
|
||||
|
||||
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({
|
||||
hostId,
|
||||
tabId,
|
||||
protocol,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
export interface GuacamoleAppHandle {
|
||||
disconnect: () => void;
|
||||
isConnected: () => boolean;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
function GuacamoleApp({ hostId, tabId, protocol }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const host = hosts.find((h) => h.id === parseInt(hostId, 10));
|
||||
setHostConfig(host ?? null);
|
||||
})
|
||||
.catch(() => setHostConfig(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [hostId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<SimpleLoader visible={true} message={t("common.loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const host = hosts.find((h) => h.id === parseInt(hostId, 10));
|
||||
setHostConfig(host ?? null);
|
||||
})
|
||||
.catch(() => setHostConfig(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [hostId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<SimpleLoader visible={true} message={t("common.loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig || !hostId) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
if (!hostConfig || !hostId) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
{t("guacamole.hostNotFound")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.hostNotFound")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GuacamoleAppInner
|
||||
hostId={parseInt(hostId, 10)}
|
||||
hostConfig={hostConfig}
|
||||
hostName={hostConfig.name || hostConfig.ip || String(hostId)}
|
||||
tabId={tabId}
|
||||
protocol={protocol}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<GuacamoleAppInner
|
||||
hostId={parseInt(hostId, 10)}
|
||||
hostConfig={hostConfig}
|
||||
hostName={hostConfig.name || hostConfig.ip || String(hostId)}
|
||||
tabId={tabId}
|
||||
protocol={protocol}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
@@ -92,13 +102,13 @@ interface GuacamoleAppInnerProps {
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
}
|
||||
|
||||
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
hostId,
|
||||
hostConfig,
|
||||
hostName,
|
||||
tabId,
|
||||
protocol,
|
||||
}) => {
|
||||
const GuacamoleAppInner = React.forwardRef<
|
||||
GuacamoleAppHandle,
|
||||
GuacamoleAppInnerProps
|
||||
>(function GuacamoleAppInner(
|
||||
{ hostId, hostConfig, hostName, tabId, protocol },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -106,6 +116,11 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const displayRef = useRef<GuacamoleDisplayHandle>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => displayRef.current?.disconnect(),
|
||||
isConnected: () => displayRef.current?.isConnected() === true,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setToken(null);
|
||||
setError(null);
|
||||
@@ -127,7 +142,7 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
||||
}, [hostId, protocol, retryCount, t]);
|
||||
}, [hostConfig.connectionType, hostId, hostName, protocol, retryCount, t]);
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
@@ -242,6 +257,6 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
<GuacamoleToolbar displayRef={displayRef} protocol={resolvedProtocol} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default GuacamoleApp;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.js";
|
||||
|
||||
const httpsLocation = {
|
||||
protocol: "https:",
|
||||
host: "termix.example.com",
|
||||
} as Location;
|
||||
|
||||
describe("buildGuacamoleWebSocketBaseUrl", () => {
|
||||
it("uses the same origin guacamole websocket path in production web builds", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/guacamole/websocket/");
|
||||
});
|
||||
|
||||
it("preserves the runtime base path for proxied web deployments", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
|
||||
});
|
||||
|
||||
it("keeps direct local websocket access for development and embedded electron", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: true,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("ws://localhost:30008");
|
||||
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: true,
|
||||
isEmbeddedApp: true,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("ws://127.0.0.1:30008");
|
||||
});
|
||||
|
||||
it("uses the configured remote server URL for electron remote mode", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: true,
|
||||
isEmbeddedApp: false,
|
||||
configuredServerUrl: "https://termix.example.com/termix/",
|
||||
basePath: "",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import Guacamole from "guacamole-common-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getGuacamoleToken, isElectron, isEmbeddedMode } from "@/main-axios.ts";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { getBasePath } from "@/lib/base-path.ts";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
|
||||
@@ -30,6 +32,7 @@ export interface GuacamoleConnectionConfig {
|
||||
|
||||
export interface GuacamoleDisplayHandle {
|
||||
disconnect: () => void;
|
||||
isConnected: () => boolean;
|
||||
sendKey: (keysym: number, pressed: boolean) => void;
|
||||
sendMouse: (x: number, y: number, buttonMask: number) => void;
|
||||
setClipboard: (data: string) => void;
|
||||
@@ -64,15 +67,28 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const windowFocusedRef = useRef(
|
||||
typeof document === "undefined" ? true : document.hasFocus(),
|
||||
);
|
||||
const hasInitiatedRef = useRef(false);
|
||||
const isMountedRef = useRef(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const disconnectClient = useCallback(() => {
|
||||
const client = clientRef.current;
|
||||
clientRef.current = null;
|
||||
isConnectingRef.current = false;
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
client.disconnect();
|
||||
} catch (error) {
|
||||
console.warn("Failed to disconnect Guacamole client", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
}
|
||||
},
|
||||
disconnect: disconnectClient,
|
||||
isConnected: () => isReady && !hasError,
|
||||
sendKey: (keysym: number, pressed: boolean) => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.sendKeyEvent(pressed ? 1 : 0, keysym);
|
||||
@@ -138,29 +154,15 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const width = connectionConfig.width ?? containerWidth ?? 1280;
|
||||
const height = connectionConfig.height ?? containerHeight ?? 720;
|
||||
|
||||
const wsBase = isDev
|
||||
? `ws://localhost:30008`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const configuredUrl = (
|
||||
window as { configuredServerUrl?: string }
|
||||
).configuredServerUrl;
|
||||
|
||||
// Embedded mode or no configured remote server: connect directly
|
||||
// to the local guacamole websocket service.
|
||||
if (isEmbeddedMode() || !configuredUrl) {
|
||||
return "ws://127.0.0.1:30008";
|
||||
}
|
||||
|
||||
const wsProtocol = configuredUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = configuredUrl
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
return `${wsProtocol}${wsHost}/guacamole/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`;
|
||||
const wsBase = buildGuacamoleWebSocketBaseUrl({
|
||||
isDev,
|
||||
isElectronApp: isElectron(),
|
||||
isEmbeddedApp: isEmbeddedMode(),
|
||||
configuredServerUrl: (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl,
|
||||
basePath: getBasePath(),
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
@@ -274,6 +276,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
if (!isMountedRef.current) {
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// The tab's DOM node can still be display:none (and report 0x0) when this
|
||||
// tab is restored in the background. Measuring then would force the
|
||||
@@ -294,6 +300,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => resolve()),
|
||||
);
|
||||
if (!isMountedRef.current) {
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
({ width: containerWidth, height: containerHeight } = measureContainer());
|
||||
}
|
||||
|
||||
@@ -303,6 +313,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
}
|
||||
|
||||
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
|
||||
if (!isMountedRef.current) {
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!wsUrl) {
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
@@ -325,12 +339,13 @@ export const GuacamoleDisplay = forwardRef<
|
||||
displayElement.style.outline = "none";
|
||||
|
||||
display.onresize = () => {
|
||||
if (!isMountedRef.current) return;
|
||||
rescaleDisplay(true);
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
const protocol = connectionConfig.protocol ?? connectionConfig.type;
|
||||
if (protocol === "telnet") {
|
||||
if (protocol === "telnet" && isMountedRef.current) {
|
||||
setIsReady(true);
|
||||
}
|
||||
|
||||
@@ -374,6 +389,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
refreshKeyboardHandlers();
|
||||
|
||||
client.onstatechange = (state: number) => {
|
||||
if (!isMountedRef.current) return;
|
||||
switch (state) {
|
||||
case 0:
|
||||
break;
|
||||
@@ -405,6 +421,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
};
|
||||
|
||||
client.onerror = (error: Guacamole.Status) => {
|
||||
if (!isMountedRef.current) return;
|
||||
const errorMessage = error.message || t("guacamole.connectionError");
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
@@ -447,7 +464,17 @@ export const GuacamoleDisplay = forwardRef<
|
||||
stream.sendAck("OK", Guacamole.Status.Code.SUCCESS);
|
||||
};
|
||||
|
||||
client.connect();
|
||||
try {
|
||||
client.connect();
|
||||
} catch (error) {
|
||||
isConnectingRef.current = false;
|
||||
if (!isMountedRef.current) return;
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
onError?.(
|
||||
error instanceof Error ? error.message : t("guacamole.connectionError"),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
getWebSocketUrl,
|
||||
onConnect,
|
||||
@@ -460,10 +487,6 @@ export const GuacamoleDisplay = forwardRef<
|
||||
t,
|
||||
]);
|
||||
|
||||
const hasInitiatedRef = useRef(false);
|
||||
const isMountedRef = useRef(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
@@ -521,13 +544,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
}
|
||||
disconnectClient();
|
||||
displayElementRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [disconnectClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export function buildGuacamoleWebSocketBaseUrl({
|
||||
isDev,
|
||||
isElectronApp,
|
||||
isEmbeddedApp,
|
||||
configuredServerUrl,
|
||||
basePath,
|
||||
location,
|
||||
}: {
|
||||
isDev: boolean;
|
||||
isElectronApp: boolean;
|
||||
isEmbeddedApp: boolean;
|
||||
configuredServerUrl?: string;
|
||||
basePath: string;
|
||||
location: Pick<Location, "protocol" | "host">;
|
||||
}) {
|
||||
if (isDev) return "ws://localhost:30008";
|
||||
if (isElectronApp) {
|
||||
if (isEmbeddedApp || !configuredServerUrl) return "ws://127.0.0.1:30008";
|
||||
|
||||
const wsProtocol = configuredServerUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = configuredServerUrl
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
return `${wsProtocol}${wsHost}/guacamole/websocket/`;
|
||||
}
|
||||
|
||||
const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${wsProtocol}://${location.host}${basePath}/guacamole/websocket/`;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import { HomepageCanvas } from "./HomepageCanvas";
|
||||
|
||||
const HomepageApp: React.FC = () => {
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden">
|
||||
<HomepageCanvas fitOnLoad={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomepageApp;
|
||||
@@ -0,0 +1,571 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import type {
|
||||
CanvasWidget,
|
||||
ContextMenuState,
|
||||
WidgetTypeId,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE, MIN_ZOOM, MAX_ZOOM } from "@/types/homepage-types";
|
||||
import {
|
||||
getHomepageItems,
|
||||
getHomepageLayout,
|
||||
createHomepageItem,
|
||||
updateHomepageItem,
|
||||
deleteHomepageItem,
|
||||
saveHomepageLayout,
|
||||
} from "@/api/homepage-api";
|
||||
import { snapToGrid } from "./canvas/snapToGrid";
|
||||
import { screenToCanvas } from "./canvas/canvasGeometry";
|
||||
import { useCanvasInput } from "./canvas/useCanvasInput";
|
||||
import { useBoxDrag } from "./canvas/useBoxDrag";
|
||||
import { useBoxResize } from "./canvas/useBoxResize";
|
||||
import { CanvasDotBackground } from "./canvas/CanvasDotBackground";
|
||||
import { WidgetShell } from "./widgets/WidgetShell";
|
||||
import { AddWidgetMenu } from "./dialogs/AddWidgetMenu";
|
||||
import { WidgetEditDialog } from "./dialogs/WidgetEditDialog";
|
||||
import { HomepageToolbar } from "./toolbar/HomepageToolbar";
|
||||
import { getWidgetType } from "./widgets/WidgetRegistry";
|
||||
|
||||
// Side-effect imports so widgets register themselves
|
||||
import "./widgets/ServiceLinkWidget";
|
||||
import "./widgets/ClockWidget";
|
||||
import "./widgets/NotesWidget";
|
||||
import "./widgets/BookmarkListWidget";
|
||||
import "./widgets/HostStatusWidget";
|
||||
import "./widgets/FolderWidget";
|
||||
import "./widgets/WeatherWidget";
|
||||
import "./widgets/IframeWidget";
|
||||
import "./widgets/RssFeedWidget";
|
||||
import "./widgets/MetricsChartWidget";
|
||||
import "./widgets/HostGridWidget";
|
||||
import "./widgets/AlertFeedWidget";
|
||||
import "./widgets/PingStatusWidget";
|
||||
import "./widgets/RecentActivityWidget";
|
||||
import "./widgets/TermixUptimeWidget";
|
||||
import "./widgets/SystemOverviewWidget";
|
||||
import "./widgets/SshTerminalWidget";
|
||||
import "./widgets/QuickConnectWidget";
|
||||
import "./widgets/FileManagerWidget";
|
||||
import "./widgets/DockerWidget";
|
||||
import "./widgets/TunnelWidget";
|
||||
import "./widgets/CalendarWidget";
|
||||
import "./widgets/CountdownWidget";
|
||||
import "./widgets/SearchBarWidget";
|
||||
import "./widgets/TextBannerWidget";
|
||||
import "./widgets/ImageWidget";
|
||||
import "./widgets/MarkdownNotesWidget";
|
||||
import "./widgets/CustomApiWidget";
|
||||
import "./widgets/ServiceGridWidget";
|
||||
import "./widgets/DashboardLinksWidget";
|
||||
import "./widgets/SearchLinksWidget";
|
||||
import "./widgets/LinkTreeWidget";
|
||||
|
||||
const CANVAS_SIZE = 100_000;
|
||||
const DEFAULT_PAN = { x: CANVAS_SIZE / 2 - 600, y: CANVAS_SIZE / 2 - 400 };
|
||||
const DEFAULT_ZOOM = 1.0;
|
||||
|
||||
interface HomepageCanvasProps {
|
||||
isReadOnly?: boolean;
|
||||
fitOnLoad?: boolean;
|
||||
onOpenFullscreen?: () => void;
|
||||
}
|
||||
|
||||
function nextZOrder(widgets: CanvasWidget[]): number {
|
||||
const nonFolders = widgets.filter((w) => w.typeId !== "folder");
|
||||
return nonFolders.length === 0
|
||||
? 1
|
||||
: Math.max(...nonFolders.map((w) => w.zOrder)) + 1;
|
||||
}
|
||||
|
||||
export function HomepageCanvas({
|
||||
isReadOnly,
|
||||
fitOnLoad,
|
||||
onOpenFullscreen,
|
||||
}: HomepageCanvasProps) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [widgets, setWidgets] = useState<CanvasWidget[]>([]);
|
||||
const [pan, setPan] = useState(DEFAULT_PAN);
|
||||
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
|
||||
const [isLocked, setIsLocked] = useState(
|
||||
() => localStorage.getItem("homepage.locked") === "true",
|
||||
);
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
const [resizingId, setResizingId] = useState<number | null>(null);
|
||||
const [editingWidget, setEditingWidget] = useState<CanvasWidget | null>(null);
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
visible: false,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
canvasX: 0,
|
||||
canvasY: 0,
|
||||
});
|
||||
const [containerSize, setContainerSize] = useState({ w: 800, h: 600 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const panRef = useRef(pan);
|
||||
const zoomRef = useRef(zoom);
|
||||
const widgetsRef = useRef(widgets);
|
||||
panRef.current = pan;
|
||||
zoomRef.current = zoom;
|
||||
widgetsRef.current = widgets;
|
||||
|
||||
const hasFitRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
setContainerSize({ w: el.offsetWidth, h: el.offsetHeight });
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([getHomepageItems(), getHomepageLayout()])
|
||||
.then(([items, layoutRow]) => {
|
||||
if (cancelled) return;
|
||||
const layoutEntries = layoutRow?.layout?.entries ?? [];
|
||||
const merged: CanvasWidget[] = items.map((item) => {
|
||||
const entry = layoutEntries.find((e) => e.itemId === item.id);
|
||||
const typeDef = getWidgetType(item.typeId as WidgetTypeId);
|
||||
return {
|
||||
id: item.id,
|
||||
typeId: item.typeId as WidgetTypeId,
|
||||
title: item.title,
|
||||
config: JSON.parse(item.config || "{}"),
|
||||
x: entry?.x ?? snapToGrid(Math.random() * 600 + 100),
|
||||
y: entry?.y ?? snapToGrid(Math.random() * 400 + 100),
|
||||
w: entry?.w ?? typeDef?.defaultSize.w ?? GRID_SIZE * 8,
|
||||
h: entry?.h ?? typeDef?.defaultSize.h ?? GRID_SIZE * 6,
|
||||
zOrder: entry?.zOrder ?? 0,
|
||||
};
|
||||
});
|
||||
setWidgets(merged);
|
||||
if (layoutRow?.layout?.pan) setPan(layoutRow.layout.pan);
|
||||
if (layoutRow?.layout?.zoom) setZoom(layoutRow.layout.zoom);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleSave = useCallback(
|
||||
(newWidgets?: CanvasWidget[], newPan?: typeof pan, newZoom?: number) => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
const ws = newWidgets ?? widgetsRef.current;
|
||||
const p = newPan ?? panRef.current;
|
||||
const z = newZoom ?? zoomRef.current;
|
||||
saveHomepageLayout({
|
||||
entries: ws.map((w) => ({
|
||||
itemId: w.id,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
zOrder: w.zOrder,
|
||||
})),
|
||||
pan: p,
|
||||
zoom: z,
|
||||
}).catch(() => {});
|
||||
}, 500);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitOnLoad || loading || widgets.length === 0 || hasFitRef.current)
|
||||
return;
|
||||
const el = containerRef.current;
|
||||
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
|
||||
hasFitRef.current = true;
|
||||
const vw = el.offsetWidth;
|
||||
const vh = el.offsetHeight;
|
||||
const minX = Math.min(...widgets.map((w) => w.x));
|
||||
const minY = Math.min(...widgets.map((w) => w.y));
|
||||
const maxX = Math.max(...widgets.map((w) => w.x + w.w));
|
||||
const maxY = Math.max(...widgets.map((w) => w.y + w.h));
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
const PADDING = 48;
|
||||
const fz = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(
|
||||
MIN_ZOOM,
|
||||
Math.min((vw - PADDING * 2) / contentW, (vh - PADDING * 2) / contentH),
|
||||
),
|
||||
);
|
||||
setPan({
|
||||
x: (vw - contentW * fz) / 2 - minX * fz,
|
||||
y: (vh - contentH * fz) / 2 - minY * fz,
|
||||
});
|
||||
setZoom(fz);
|
||||
}, [fitOnLoad, loading, widgets, containerSize]);
|
||||
|
||||
const handlePanChange = useCallback(
|
||||
(newPan: typeof pan) => {
|
||||
setPan(newPan);
|
||||
scheduleSave(undefined, newPan, undefined);
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(newZoom: number, newPan: typeof pan) => {
|
||||
setZoom(newZoom);
|
||||
setPan(newPan);
|
||||
scheduleSave(undefined, newPan, newZoom);
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const canvasInput = useCanvasInput({
|
||||
isDraggingWidget: draggingId !== null,
|
||||
isResizing: resizingId !== null,
|
||||
isContextMenuVisible: contextMenu.visible,
|
||||
onPanChange: handlePanChange,
|
||||
onZoomChange: handleZoomChange,
|
||||
getPan: () => panRef.current,
|
||||
getZoom: () => zoomRef.current,
|
||||
containerRef,
|
||||
});
|
||||
|
||||
const handleWidgetMove = useCallback(
|
||||
(id: number, x: number, y: number) => {
|
||||
setWidgets((prev) => {
|
||||
const next = prev.map((w) => (w.id === id ? { ...w, x, y } : w));
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleWidgetResize = useCallback(
|
||||
(id: number, w: number, h: number) => {
|
||||
setWidgets((prev) => {
|
||||
const next = prev.map((widget) =>
|
||||
widget.id === id ? { ...widget, w, h } : widget,
|
||||
);
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(id: number) => {
|
||||
setDraggingId(id);
|
||||
setWidgets((prev) => {
|
||||
const widget = prev.find((w) => w.id === id);
|
||||
if (!widget || widget.typeId === "folder") return prev;
|
||||
const top = nextZOrder(prev);
|
||||
const next = prev.map((w) => (w.id === id ? { ...w, zOrder: top } : w));
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const { startDrag } = useBoxDrag({
|
||||
widgets,
|
||||
zoom,
|
||||
getZoom: () => zoomRef.current,
|
||||
onWidgetMove: handleWidgetMove,
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: () => setDraggingId(null),
|
||||
});
|
||||
|
||||
const { startResize } = useBoxResize({
|
||||
widgets,
|
||||
getZoom: () => zoomRef.current,
|
||||
onWidgetResize: handleWidgetResize,
|
||||
onResizeStart: setResizingId,
|
||||
onResizeEnd: () => setResizingId(null),
|
||||
});
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isLocked || isReadOnly) return;
|
||||
if ((e.target as HTMLElement).closest("[data-widget]")) return;
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
const { x, y } = screenToCanvas(sx, sy, panRef.current, zoomRef.current);
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
screenX: e.clientX,
|
||||
screenY: e.clientY,
|
||||
canvasX: snapToGrid(x),
|
||||
canvasY: snapToGrid(y),
|
||||
});
|
||||
},
|
||||
[isLocked, isReadOnly],
|
||||
);
|
||||
|
||||
const handleAddWidget = useCallback(
|
||||
async (typeId: WidgetTypeId, canvasX: number, canvasY: number) => {
|
||||
const typeDef = getWidgetType(typeId);
|
||||
if (!typeDef) return;
|
||||
try {
|
||||
const item = await createHomepageItem({
|
||||
typeId,
|
||||
config: typeDef.defaultConfig as Record<string, unknown>,
|
||||
});
|
||||
setWidgets((prev) => {
|
||||
const zOrder = typeId === "folder" ? 0 : nextZOrder(prev);
|
||||
const newWidget: CanvasWidget = {
|
||||
id: item.id,
|
||||
typeId,
|
||||
title: item.title,
|
||||
config: typeDef.defaultConfig as Record<string, unknown>,
|
||||
x: canvasX,
|
||||
y: canvasY,
|
||||
w: typeDef.defaultSize.w,
|
||||
h: typeDef.defaultSize.h,
|
||||
zOrder,
|
||||
};
|
||||
const next = [...prev, newWidget];
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleOpenAddMenu = useCallback(
|
||||
(anchorRect: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const w = containerSize.w;
|
||||
const h = containerSize.h;
|
||||
const { x, y } = screenToCanvas(
|
||||
w / 2,
|
||||
h / 2,
|
||||
panRef.current,
|
||||
zoomRef.current,
|
||||
);
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
screenX: anchorRect.left,
|
||||
screenY: anchorRect.top,
|
||||
canvasX: snapToGrid(x),
|
||||
canvasY: snapToGrid(y),
|
||||
anchorRect,
|
||||
});
|
||||
},
|
||||
[containerSize],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
await deleteHomepageItem(id);
|
||||
setWidgets((prev) => {
|
||||
const next = prev.filter((w) => w.id !== id);
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(id: number) => {
|
||||
const w = widgets.find((x) => x.id === id);
|
||||
if (w) {
|
||||
setEditingWidget(w);
|
||||
setDialogKey((k) => k + 1);
|
||||
}
|
||||
},
|
||||
[widgets],
|
||||
);
|
||||
|
||||
const handleSaveEdit = useCallback(
|
||||
async (
|
||||
id: number,
|
||||
title: string | null,
|
||||
config: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
await updateHomepageItem(id, { title, config });
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) => (w.id === id ? { ...w, title, config } : w)),
|
||||
);
|
||||
} catch {}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
const ws = widgetsRef.current;
|
||||
const el = containerRef.current;
|
||||
const vw = el?.offsetWidth ?? 800;
|
||||
const vh = el?.offsetHeight ?? 600;
|
||||
|
||||
if (ws.length === 0) {
|
||||
setPan(DEFAULT_PAN);
|
||||
setZoom(DEFAULT_ZOOM);
|
||||
return;
|
||||
}
|
||||
|
||||
const minX = Math.min(...ws.map((w) => w.x));
|
||||
const minY = Math.min(...ws.map((w) => w.y));
|
||||
const maxX = Math.max(...ws.map((w) => w.x + w.w));
|
||||
const maxY = Math.max(...ws.map((w) => w.y + w.h));
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
|
||||
const PADDING = 48;
|
||||
const fitZoom = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(
|
||||
MIN_ZOOM,
|
||||
Math.min((vw - PADDING * 2) / contentW, (vh - PADDING * 2) / contentH),
|
||||
),
|
||||
);
|
||||
|
||||
// Place the content bbox center at the viewport center
|
||||
const newPan = {
|
||||
x: (vw - contentW * fitZoom) / 2 - minX * fitZoom,
|
||||
y: (vh - contentH * fitZoom) / 2 - minY * fitZoom,
|
||||
};
|
||||
|
||||
setZoom(fitZoom);
|
||||
setPan(newPan);
|
||||
scheduleSave(undefined, newPan, fitZoom);
|
||||
}, [scheduleSave]);
|
||||
|
||||
// Folders always render first (lowest z), non-folders sorted by zOrder ascending
|
||||
const sortedWidgets = [...widgets].sort((a, b) => {
|
||||
const aFolder = a.typeId === "folder";
|
||||
const bFolder = b.typeId === "folder";
|
||||
if (aFolder && !bFolder) return -1;
|
||||
if (!aFolder && bFolder) return 1;
|
||||
return a.zOrder - b.zOrder;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-muted-foreground text-sm">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-full overflow-hidden bg-background"
|
||||
onMouseDown={editingWidget ? undefined : canvasInput.handleMouseDown}
|
||||
onMouseMove={canvasInput.handleMouseMove}
|
||||
onMouseUp={canvasInput.handleMouseUp}
|
||||
onMouseLeave={canvasInput.handleMouseUp}
|
||||
onWheel={canvasInput.handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ cursor: draggingId ? "grabbing" : "default" }}
|
||||
>
|
||||
<CanvasDotBackground pan={pan} zoom={zoom} />
|
||||
|
||||
<div
|
||||
className="absolute origin-top-left"
|
||||
style={{
|
||||
width: CANVAS_SIZE,
|
||||
height: CANVAS_SIZE,
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
{sortedWidgets.map((widget) => (
|
||||
<WidgetShell
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
isLocked={isLocked}
|
||||
isReadOnly={isReadOnly}
|
||||
isDragging={draggingId === widget.id}
|
||||
isResizing={resizingId === widget.id}
|
||||
onStartDrag={startDrag}
|
||||
onStartResize={startResize}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onConfigUpdate={(config) =>
|
||||
handleSaveEdit(widget.id, widget.title, config)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{widgets.length === 0 && !isReadOnly && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none">
|
||||
<div className="flex items-center gap-2 bg-card border border-border px-3 py-2 shadow-sm">
|
||||
<LayoutGrid className="size-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{t("homepage.noWidgets")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isReadOnly && onOpenFullscreen && (
|
||||
<button
|
||||
className="absolute top-2 right-2 z-20 text-xs text-muted-foreground hover:text-foreground bg-card/80 border border-border px-2 py-1"
|
||||
onClick={onOpenFullscreen}
|
||||
>
|
||||
{t("homepage.openFullView")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isReadOnly && (
|
||||
<HomepageToolbar
|
||||
zoom={zoom}
|
||||
isLocked={isLocked}
|
||||
pan={pan}
|
||||
containerSize={containerSize}
|
||||
onZoomChange={handleZoomChange}
|
||||
onLockToggle={() =>
|
||||
setIsLocked((v) => {
|
||||
const next = !v;
|
||||
localStorage.setItem("homepage.locked", String(next));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onResetView={resetView}
|
||||
onAddWidget={handleOpenAddMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddWidgetMenu
|
||||
state={contextMenu}
|
||||
onAdd={handleAddWidget}
|
||||
onClose={() => setContextMenu((s) => ({ ...s, visible: false }))}
|
||||
/>
|
||||
|
||||
<WidgetEditDialog
|
||||
key={dialogKey}
|
||||
widget={editingWidget}
|
||||
onSave={handleSaveEdit}
|
||||
onClose={() => setEditingWidget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface Props {
|
||||
pan: { x: number; y: number };
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export function CanvasDotBackground({ pan, zoom }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.offsetWidth;
|
||||
const h = canvas.offsetHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Read dot color from CSS variable for theme support
|
||||
const dotColor =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--color-muted-foreground")
|
||||
.trim() || "#6b7280";
|
||||
|
||||
// Effective grid spacing in screen pixels
|
||||
const spacing = 30 * zoom;
|
||||
|
||||
// Skip rendering dots that are too close together or too far apart
|
||||
if (spacing < 8 || spacing > 200) return;
|
||||
|
||||
// Offset so dots align with the canvas pan position
|
||||
const offsetX = ((pan.x % spacing) + spacing) % spacing;
|
||||
const offsetY = ((pan.y % spacing) + spacing) % spacing;
|
||||
|
||||
const dotRadius = Math.max(0.8, Math.min(1.5, zoom * 1.2));
|
||||
|
||||
ctx.fillStyle = dotColor;
|
||||
ctx.globalAlpha = 0.35;
|
||||
|
||||
for (let x = offsetX; x < w; x += spacing) {
|
||||
for (let y = offsetY; y < h; y += spacing) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, dotRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}, [pan, zoom]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
screenToCanvas,
|
||||
canvasToScreen,
|
||||
zoomAroundPoint,
|
||||
} from "./canvasGeometry";
|
||||
|
||||
const PAN = { x: 100, y: 200 };
|
||||
const ZOOM = 2;
|
||||
|
||||
describe("screenToCanvas", () => {
|
||||
it("inverts canvasToScreen", () => {
|
||||
const canvas = { x: 50, y: 75 };
|
||||
const screen = canvasToScreen(canvas.x, canvas.y, PAN, ZOOM);
|
||||
const back = screenToCanvas(screen.x, screen.y, PAN, ZOOM);
|
||||
expect(back.x).toBeCloseTo(canvas.x);
|
||||
expect(back.y).toBeCloseTo(canvas.y);
|
||||
});
|
||||
|
||||
it("accounts for pan offset", () => {
|
||||
const result = screenToCanvas(100, 200, PAN, 1);
|
||||
expect(result.x).toBe(0);
|
||||
expect(result.y).toBe(0);
|
||||
});
|
||||
|
||||
it("accounts for zoom", () => {
|
||||
const result = screenToCanvas(300, 400, PAN, ZOOM);
|
||||
expect(result.x).toBe((300 - 100) / 2);
|
||||
expect(result.y).toBe((400 - 200) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvasToScreen", () => {
|
||||
it("maps origin with pan offset", () => {
|
||||
const result = canvasToScreen(0, 0, PAN, ZOOM);
|
||||
expect(result.x).toBe(100);
|
||||
expect(result.y).toBe(200);
|
||||
});
|
||||
|
||||
it("scales by zoom", () => {
|
||||
const result = canvasToScreen(10, 20, PAN, ZOOM);
|
||||
expect(result.x).toBe(120);
|
||||
expect(result.y).toBe(240);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zoomAroundPoint", () => {
|
||||
it("keeps the mouse point on the same canvas coordinate", () => {
|
||||
const mouseX = 400;
|
||||
const mouseY = 300;
|
||||
const pan = { x: 0, y: 0 };
|
||||
const oldZoom = 1;
|
||||
const newZoom = 2;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom);
|
||||
|
||||
// Canvas coordinate under mouse before zoom
|
||||
const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom);
|
||||
// Canvas coordinate under mouse after zoom with newPan
|
||||
const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom);
|
||||
|
||||
expect(canvasAfter.x).toBeCloseTo(canvasBefore.x);
|
||||
expect(canvasAfter.y).toBeCloseTo(canvasBefore.y);
|
||||
});
|
||||
|
||||
it("returns correct pan for zoom-out", () => {
|
||||
const mouseX = 200;
|
||||
const mouseY = 150;
|
||||
const pan = { x: 50, y: 50 };
|
||||
const oldZoom = 2;
|
||||
const newZoom = 1;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom);
|
||||
const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom);
|
||||
const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom);
|
||||
|
||||
expect(canvasAfter.x).toBeCloseTo(canvasBefore.x);
|
||||
expect(canvasAfter.y).toBeCloseTo(canvasBefore.y);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export function screenToCanvas(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
pan: { x: number; y: number },
|
||||
zoom: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: (screenX - pan.x) / zoom,
|
||||
y: (screenY - pan.y) / zoom,
|
||||
};
|
||||
}
|
||||
|
||||
export function canvasToScreen(
|
||||
canvasX: number,
|
||||
canvasY: number,
|
||||
pan: { x: number; y: number },
|
||||
zoom: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: canvasX * zoom + pan.x,
|
||||
y: canvasY * zoom + pan.y,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pan value that keeps the canvas point (gridX, gridY) under (mouseX, mouseY) after zoom changes. */
|
||||
export function zoomAroundPoint(
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
pan: { x: number; y: number },
|
||||
oldZoom: number,
|
||||
newZoom: number,
|
||||
): { x: number; y: number } {
|
||||
const gridX = (mouseX - pan.x) / oldZoom;
|
||||
const gridY = (mouseY - pan.y) / oldZoom;
|
||||
return {
|
||||
x: mouseX - gridX * newZoom,
|
||||
y: mouseY - gridY * newZoom,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { snapToGrid, snapToGridFloor } from "./snapToGrid";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
describe("snapToGrid", () => {
|
||||
it("snaps to nearest grid multiple", () => {
|
||||
expect(snapToGrid(0)).toBe(0);
|
||||
expect(snapToGrid(GRID_SIZE)).toBe(GRID_SIZE);
|
||||
expect(snapToGrid(GRID_SIZE * 2)).toBe(GRID_SIZE * 2);
|
||||
});
|
||||
|
||||
it("rounds to nearest (up when exactly halfway)", () => {
|
||||
expect(snapToGrid(GRID_SIZE / 2)).toBe(GRID_SIZE);
|
||||
expect(snapToGrid(GRID_SIZE * 1.5)).toBe(GRID_SIZE * 2);
|
||||
});
|
||||
|
||||
it("rounds down when below halfway", () => {
|
||||
expect(snapToGrid(GRID_SIZE * 0.4)).toBe(0);
|
||||
expect(snapToGrid(GRID_SIZE * 1.4)).toBe(GRID_SIZE);
|
||||
});
|
||||
|
||||
it("works with negative values", () => {
|
||||
expect(snapToGrid(-GRID_SIZE)).toBe(-GRID_SIZE);
|
||||
expect(snapToGrid(-GRID_SIZE * 0.4)).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapToGridFloor", () => {
|
||||
it("always rounds down to floor multiple", () => {
|
||||
expect(snapToGridFloor(GRID_SIZE - 1)).toBe(0);
|
||||
expect(snapToGridFloor(GRID_SIZE)).toBe(GRID_SIZE);
|
||||
expect(snapToGridFloor(GRID_SIZE + 1)).toBe(GRID_SIZE);
|
||||
expect(snapToGridFloor(GRID_SIZE * 2 - 1)).toBe(GRID_SIZE);
|
||||
});
|
||||
|
||||
it("handles zero", () => {
|
||||
expect(snapToGridFloor(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
export function snapToGrid(value: number): number {
|
||||
return Math.round(value / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
|
||||
export function snapToGridFloor(value: number): number {
|
||||
return Math.floor(value / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { CanvasWidget, DragState } from "@/types/homepage-types";
|
||||
import { snapToGrid } from "./snapToGrid";
|
||||
|
||||
interface UseBoxDragOptions {
|
||||
widgets: CanvasWidget[];
|
||||
zoom: number;
|
||||
getZoom: () => number;
|
||||
onWidgetMove: (id: number, x: number, y: number) => void;
|
||||
onDragStart: (id: number) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
export function useBoxDrag({
|
||||
widgets,
|
||||
getZoom,
|
||||
onWidgetMove,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: UseBoxDragOptions) {
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.MouseEvent, widget: CanvasWidget) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
dragRef.current = {
|
||||
widgetId: widget.id,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startWidgetX: widget.x,
|
||||
startWidgetY: widget.y,
|
||||
};
|
||||
onDragStart(widget.id);
|
||||
|
||||
const handleMove = (ev: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const zoom = getZoom();
|
||||
const dx = (ev.clientX - dragRef.current.startMouseX) / zoom;
|
||||
const dy = (ev.clientY - dragRef.current.startMouseY) / zoom;
|
||||
const newX = snapToGrid(dragRef.current.startWidgetX + dx);
|
||||
const newY = snapToGrid(dragRef.current.startWidgetY + dy);
|
||||
|
||||
const moving = widgets.find((w) => w.id === dragRef.current!.widgetId);
|
||||
if (!moving) return;
|
||||
|
||||
if (newX !== moving.x || newY !== moving.y) {
|
||||
onWidgetMove(moving.id, newX, newY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
dragRef.current = null;
|
||||
onDragEnd();
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[widgets, getZoom, onWidgetMove, onDragStart, onDragEnd],
|
||||
);
|
||||
|
||||
return { startDrag };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import type { CanvasWidget, ResizeState } from "@/types/homepage-types";
|
||||
import { snapToGrid } from "./snapToGrid";
|
||||
import { getWidgetType } from "../widgets/WidgetRegistry";
|
||||
|
||||
interface UseBoxResizeOptions {
|
||||
widgets: CanvasWidget[];
|
||||
getZoom: () => number;
|
||||
onWidgetResize: (id: number, w: number, h: number) => void;
|
||||
onResizeStart: (id: number) => void;
|
||||
onResizeEnd: () => void;
|
||||
}
|
||||
|
||||
export function useBoxResize({
|
||||
widgets,
|
||||
getZoom,
|
||||
onWidgetResize,
|
||||
onResizeStart,
|
||||
onResizeEnd,
|
||||
}: UseBoxResizeOptions) {
|
||||
const resizeRef = useRef<ResizeState | null>(null);
|
||||
|
||||
const startResize = useCallback(
|
||||
(e: React.MouseEvent, widget: CanvasWidget) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
resizeRef.current = {
|
||||
widgetId: widget.id,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startW: widget.w,
|
||||
startH: widget.h,
|
||||
};
|
||||
onResizeStart(widget.id);
|
||||
|
||||
const handleMove = (ev: MouseEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const zoom = getZoom();
|
||||
const dx = (ev.clientX - resizeRef.current.startMouseX) / zoom;
|
||||
const dy = (ev.clientY - resizeRef.current.startMouseY) / zoom;
|
||||
const newW = Math.max(
|
||||
GRID_SIZE * 2,
|
||||
snapToGrid(resizeRef.current.startW + dx),
|
||||
);
|
||||
const newH = Math.max(
|
||||
GRID_SIZE * 2,
|
||||
snapToGrid(resizeRef.current.startH + dy),
|
||||
);
|
||||
|
||||
const moving = widgets.find(
|
||||
(w) => w.id === resizeRef.current!.widgetId,
|
||||
);
|
||||
if (!moving) return;
|
||||
|
||||
const typeDef = getWidgetType(moving.typeId);
|
||||
const minW = typeDef?.minSize.w ?? GRID_SIZE * 4;
|
||||
const minH = typeDef?.minSize.h ?? GRID_SIZE * 4;
|
||||
const clampedW = Math.max(minW, newW);
|
||||
const clampedH = Math.max(minH, newH);
|
||||
|
||||
if (clampedW !== moving.w || clampedH !== moving.h) {
|
||||
onWidgetResize(moving.id, clampedW, clampedH);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
resizeRef.current = null;
|
||||
onResizeEnd();
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[widgets, getZoom, onWidgetResize, onResizeStart, onResizeEnd],
|
||||
);
|
||||
|
||||
return { startResize };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { MIN_ZOOM, MAX_ZOOM } from "@/types/homepage-types";
|
||||
import { zoomAroundPoint } from "./canvasGeometry";
|
||||
|
||||
interface UseCanvasInputOptions {
|
||||
isDraggingWidget: boolean;
|
||||
isResizing: boolean;
|
||||
isContextMenuVisible: boolean;
|
||||
onPanChange: (pan: { x: number; y: number }) => void;
|
||||
onZoomChange: (zoom: number, pan: { x: number; y: number }) => void;
|
||||
getPan: () => { x: number; y: number };
|
||||
getZoom: () => number;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function useCanvasInput({
|
||||
isDraggingWidget,
|
||||
isResizing,
|
||||
isContextMenuVisible,
|
||||
onPanChange,
|
||||
onZoomChange,
|
||||
getPan,
|
||||
getZoom,
|
||||
containerRef,
|
||||
}: UseCanvasInputOptions) {
|
||||
const isPanningRef = useRef(false);
|
||||
const lastMouseRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only pan on the background (not on widgets), left button
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("[data-widget]")) return;
|
||||
if (isDraggingWidget || isResizing || isContextMenuVisible) return;
|
||||
|
||||
isPanningRef.current = true;
|
||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
e.preventDefault();
|
||||
},
|
||||
[isDraggingWidget, isResizing, isContextMenuVisible],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isPanningRef.current) return;
|
||||
const dx = e.clientX - lastMouseRef.current.x;
|
||||
const dy = e.clientY - lastMouseRef.current.y;
|
||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
const pan = getPan();
|
||||
onPanChange({ x: pan.x + dx, y: pan.y + dy });
|
||||
},
|
||||
[getPan, onPanChange],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isPanningRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const zoom = getZoom();
|
||||
const pan = getPan();
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
// Multiplicative zoom so each step is proportional regardless of current zoom level.
|
||||
// Normalize deltaY to avoid trackpad sending huge values.
|
||||
const rawDelta = Math.sign(e.deltaY) * Math.min(Math.abs(e.deltaY), 100);
|
||||
const factor = 1 - rawDelta * 0.001;
|
||||
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom * factor));
|
||||
if (newZoom === zoom) return;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, zoom, newZoom);
|
||||
onZoomChange(newZoom, newPan);
|
||||
},
|
||||
[getZoom, getPan, containerRef, onZoomChange],
|
||||
);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp, handleWheel };
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAllWidgetTypes } from "../widgets/WidgetRegistry";
|
||||
import type { ContextMenuState, WidgetTypeId } from "@/types/homepage-types";
|
||||
|
||||
interface AddWidgetMenuProps {
|
||||
state: ContextMenuState;
|
||||
onAdd: (typeId: WidgetTypeId, canvasX: number, canvasY: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER = ["links", "info", "system", "monitoring"] as const;
|
||||
|
||||
const MARGIN = 8;
|
||||
|
||||
export function AddWidgetMenu({ state, onAdd, onClose }: AddWidgetMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const allTypes = getAllWidgetTypes();
|
||||
const [pos, setPos] = useState({ left: state.screenX, top: state.screenY });
|
||||
const [selectedCat, setSelectedCat] = useState<
|
||||
(typeof CATEGORY_ORDER)[number]
|
||||
>(CATEGORY_ORDER[0]);
|
||||
const categoryLabels: Record<(typeof CATEGORY_ORDER)[number], string> = {
|
||||
links: t("homepage.categoryLinks"),
|
||||
info: t("homepage.categoryInfo"),
|
||||
system: t("homepage.categorySystem"),
|
||||
monitoring: t("homepage.categoryMonitoring"),
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!state.visible || !menuRef.current) return;
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let left = state.screenX;
|
||||
let top = state.screenY;
|
||||
|
||||
if (state.anchorRect) {
|
||||
left = state.anchorRect.right - rect.width;
|
||||
top = state.anchorRect.top - rect.height - MARGIN;
|
||||
}
|
||||
|
||||
if (left + rect.width > vw - MARGIN) left = vw - rect.width - MARGIN;
|
||||
if (left < MARGIN) left = MARGIN;
|
||||
|
||||
if (top < MARGIN) {
|
||||
top = state.anchorRect
|
||||
? state.anchorRect.bottom + MARGIN
|
||||
: state.screenY + MARGIN;
|
||||
}
|
||||
if (top + rect.height > vh - MARGIN) top = vh - rect.height - MARGIN;
|
||||
if (top < MARGIN) top = MARGIN;
|
||||
|
||||
setPos({ left, top });
|
||||
}, [state.visible, state.screenX, state.screenY, state.anchorRect]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const visibleCategories = CATEGORY_ORDER.filter((cat) =>
|
||||
allTypes.some((type) => type.category === cat),
|
||||
);
|
||||
|
||||
const selectedTypes = allTypes.filter(
|
||||
(type) => type.category === selectedCat,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-card border border-border shadow-xl min-w-[220px] flex flex-col"
|
||||
style={{
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
visibility: state.visible ? "visible" : "hidden",
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-accent-brand">
|
||||
{t("homepage.addWidget")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className="flex items-center gap-0.5 px-2 pt-2 pb-1 shrink-0">
|
||||
{visibleCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCat(cat)}
|
||||
className={`px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider transition-colors ${
|
||||
selectedCat === cat
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
{categoryLabels[cat]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Widget list */}
|
||||
<div className="overflow-y-auto" style={{ maxHeight: "240px" }}>
|
||||
{selectedTypes.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground/60">
|
||||
No widgets in this category
|
||||
</div>
|
||||
) : (
|
||||
selectedTypes.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left text-xs text-foreground hover:bg-muted/60 transition-colors"
|
||||
onClick={() => {
|
||||
onAdd(type.id, state.canvasX, state.canvasY);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="text-accent-brand shrink-0">{type.icon}</span>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium truncate">{type.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{type.description}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
AlertFeedConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { Input } from "@/components/input";
|
||||
|
||||
export function AlertFeedEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<AlertFeedConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.maxItems}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: Math.max(1, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showAcknowledged}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showAcknowledged: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showAcknowledged")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import type {
|
||||
BookmarkListConfig,
|
||||
BookmarkLink,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function BookmarkListEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<BookmarkListConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { links } = config;
|
||||
|
||||
const updateLink = (i: number, patch: Partial<BookmarkLink>) => {
|
||||
const next = links.map((l, idx) => (idx === i ? { ...l, ...patch } : l));
|
||||
onChange({ ...config, links: next });
|
||||
};
|
||||
|
||||
const removeLink = (i: number) => {
|
||||
onChange({ ...config, links: links.filter((_, idx) => idx !== i) });
|
||||
};
|
||||
|
||||
const addLink = () => {
|
||||
onChange({ ...config, links: [...links, { label: "", url: "" }] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 max-h-60 overflow-y-auto pr-1">
|
||||
{links.map((link, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={link.label}
|
||||
onChange={(e) => updateLink(i, { label: e.target.value })}
|
||||
placeholder={t("homepage.linkLabel")}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={link.url}
|
||||
onChange={(e) => updateLink(i, { url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeLink(i)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLink}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addLink")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
CalendarConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function CalendarEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<CalendarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.timezone")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.timezone ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, timezone: e.target.value || undefined })
|
||||
}
|
||||
placeholder="UTC"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.startOnMonday}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, startOnMonday: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.startOnMonday")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type { ClockConfig, WidgetEditFormProps } from "@/types/homepage-types";
|
||||
|
||||
export function ClockEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ClockConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.timezone")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.timezone ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, timezone: e.target.value || undefined })
|
||||
}
|
||||
placeholder="America/New_York (leave blank for local)"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.showSeconds")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.showSeconds}
|
||||
onCheckedChange={(v) => onChange({ ...config, showSeconds: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.format12h")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.format === "12h"}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...config, format: v ? "12h" : "24h" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
CountdownConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function CountdownEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<CountdownConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.countdownLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.label}
|
||||
onChange={(e) => onChange({ ...config, label: e.target.value })}
|
||||
placeholder={t("homepage.countdownLabelPlaceholder")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.targetDate")}
|
||||
</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={config.targetDate}
|
||||
onChange={(e) => onChange({ ...config, targetDate: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showDays}
|
||||
onChange={(e) => onChange({ ...config, showDays: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.countdownShowDays")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showHours}
|
||||
onChange={(e) => onChange({ ...config, showHours: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.countdownShowHours")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
CustomApiConfig,
|
||||
CustomApiDisplayMode,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
const MODES: { id: CustomApiDisplayMode; label: string }[] = [
|
||||
{ id: "value", label: "Value" },
|
||||
{ id: "json", label: "JSON" },
|
||||
{ id: "table", label: "Table" },
|
||||
];
|
||||
|
||||
export function CustomApiEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<CustomApiConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.url")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.url}
|
||||
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||
placeholder="https://api.example.com/data"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.displayMode")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, displayMode: m.id })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors ${config.displayMode === m.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.displayMode === "value" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.displayField")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.displayField ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
displayField: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="data.temperature"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.customApiLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.label ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, label: e.target.value || undefined })
|
||||
}
|
||||
placeholder={t("homepage.customApiLabelPlaceholder")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-20">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.customApiUnit")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.unit ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, unit: e.target.value || undefined })
|
||||
}
|
||||
placeholder="°C"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.displayMode === "table" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.jsonPath")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.jsonPath ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, jsonPath: e.target.value || undefined })
|
||||
}
|
||||
placeholder="data.items"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.refreshInterval")} ({t("homepage.seconds")})
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={86400}
|
||||
value={config.refreshInterval}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
refreshInterval: Math.max(10, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
DashboardLinksConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function DashboardLinksEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<DashboardLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.columns")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{([1, 2, 3] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, columns: c })}
|
||||
className={`px-3 py-0.5 text-[10px] border transition-colors ${config.columns === c ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={config.maxItems ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: e.target.value ? Number(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
placeholder={t("homepage.noLimit")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showIcons}
|
||||
onChange={(e) => onChange({ ...config, showIcons: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showImage")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
DockerActivityConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function DockerActivityEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<DockerActivityConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.maxItems}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: Math.max(1, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showHostName}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showHostName: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showHostName")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
DockerWidgetConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { SingleHostEditForm } from "./SingleHostEditForm";
|
||||
|
||||
export function DockerWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<DockerWidgetConfig>) {
|
||||
return (
|
||||
<SingleHostEditForm
|
||||
hostId={config.hostId}
|
||||
onChange={(hostId) => onChange({ ...config, hostId })}
|
||||
filter={(h) => !!(h.enableSsh && h.enableDocker)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
FileManagerWidgetConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { SingleHostEditForm } from "./SingleHostEditForm";
|
||||
|
||||
export function FileManagerWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<FileManagerWidgetConfig>) {
|
||||
return (
|
||||
<SingleHostEditForm
|
||||
hostId={config.hostId}
|
||||
onChange={(hostId) => onChange({ ...config, hostId })}
|
||||
filter={(h) => !!(h.enableSsh && h.enableFileManager)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type { FolderConfig, WidgetEditFormProps } from "@/types/homepage-types";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<FolderConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const accent = getAccentColor();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.color")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.color || accent}
|
||||
onChange={(e) => onChange({ ...config, color: e.target.value })}
|
||||
className="w-8 h-8 border border-border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={config.color ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, color: e.target.value || undefined })
|
||||
}
|
||||
placeholder={accent}
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
HostGridConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
|
||||
export function HostGridEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<HostGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleHost = (id: number) => {
|
||||
const next = config.hostIds.includes(id)
|
||||
? config.hostIds.filter((x) => x !== id)
|
||||
: [...config.hostIds, id];
|
||||
onChange({ ...config, hostIds: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.hostGridHosts")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-0.5 max-h-40 overflow-y-auto border border-border/40 p-1">
|
||||
{hosts.length === 0 && (
|
||||
<span className="text-[10px] text-muted-foreground p-1">
|
||||
{t("homepage.noHosts")}
|
||||
</span>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<label
|
||||
key={h.id}
|
||||
className="flex items-center gap-2 text-[10px] cursor-pointer px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hostIds.includes(h.id)}
|
||||
onChange={() => toggleHost(h.id)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{h.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{t("homepage.hostGridAllHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.columns")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{([2, 3, 4] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, columns: c })}
|
||||
className={`px-3 py-0.5 text-[10px] font-medium border transition-colors ${config.columns === c ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showIp}
|
||||
onChange={(e) => onChange({ ...config, showIp: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showIp")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type {
|
||||
HostMetricKey,
|
||||
HostStatusConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
interface SimpleHost {
|
||||
id: string;
|
||||
name: string;
|
||||
metricsEnabled: boolean;
|
||||
}
|
||||
|
||||
const METRIC_OPTIONS: { key: HostMetricKey; labelKey: string }[] = [
|
||||
{ key: "cpu", labelKey: "homepage.metricCpu" },
|
||||
{ key: "memory", labelKey: "homepage.metricMemory" },
|
||||
{ key: "disk", labelKey: "homepage.metricDisk" },
|
||||
{ key: "uptime", labelKey: "homepage.metricUptime" },
|
||||
{ key: "system", labelKey: "homepage.metricSystem" },
|
||||
{ key: "network", labelKey: "homepage.metricNetwork" },
|
||||
{ key: "processes", labelKey: "homepage.metricProcesses" },
|
||||
];
|
||||
|
||||
function parseMetricsEnabled(statsConfig: unknown): boolean {
|
||||
if (!statsConfig) return false;
|
||||
try {
|
||||
const cfg =
|
||||
typeof statsConfig === "string" ? JSON.parse(statsConfig) : statsConfig;
|
||||
if (typeof cfg === "object" && cfg !== null) {
|
||||
const c = cfg as Record<string, unknown>;
|
||||
return c.metricsEnabled !== false;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function HostStatusEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<HostStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SimpleHost[]>([]);
|
||||
|
||||
const shownMetrics: HostMetricKey[] = config.shownMetrics?.length
|
||||
? config.shownMetrics
|
||||
: config.showMetrics !== false
|
||||
? ["cpu", "memory", ...(config.showDisk ? ["disk" as HostMetricKey] : [])]
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((all) =>
|
||||
setHosts(
|
||||
all
|
||||
.filter((x) => parseMetricsEnabled(x.statsConfig))
|
||||
.map((x) => ({
|
||||
id: String(x.id),
|
||||
name: x.name,
|
||||
metricsEnabled: true,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function toggleMetric(key: HostMetricKey) {
|
||||
const next = shownMetrics.includes(key)
|
||||
? shownMetrics.filter((k) => k !== key)
|
||||
: [...shownMetrics, key];
|
||||
onChange({ ...config, shownMetrics: next });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<select
|
||||
className="h-8 border border-border bg-background text-sm px-2"
|
||||
value={config.hostId || ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, hostId: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
>
|
||||
<option value="">{t("homepage.selectHost")}</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.displayedMetrics")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
{METRIC_OPTIONS.map(({ key, labelKey }) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shownMetrics.includes(key)}
|
||||
onChange={() => toggleMetric(key)}
|
||||
className="w-3.5 h-3.5 accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-foreground">{t(labelKey)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type { IframeConfig, WidgetEditFormProps } from "@/types/homepage-types";
|
||||
|
||||
export function IframeEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<IframeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.url")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.url}
|
||||
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.scrolling")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.scrolling}
|
||||
onCheckedChange={(v) => onChange({ ...config, scrolling: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
ImageWidgetConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function ImageWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ImageWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.imageUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.imageUrl}
|
||||
onChange={(e) => onChange({ ...config, imageUrl: e.target.value })}
|
||||
placeholder="https://example.com/image.png"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.imageLinkUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.linkUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, linkUrl: e.target.value || undefined })
|
||||
}
|
||||
placeholder="https://example.com"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.imageFit")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["contain", "cover", "fill"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, fit: f })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors capitalize ${config.fit === f ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.altText")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.alt ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, alt: e.target.value || undefined })
|
||||
}
|
||||
placeholder={t("homepage.altTextPlaceholder")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import type {
|
||||
LinkTreeConfig,
|
||||
LinkTreeSection,
|
||||
LinkTreeLink,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function LinkTreeEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<LinkTreeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { sections, compact } = config;
|
||||
|
||||
const updateSection = (si: number, patch: Partial<LinkTreeSection>) => {
|
||||
onChange({
|
||||
...config,
|
||||
sections: sections.map((s, i) => (i === si ? { ...s, ...patch } : s)),
|
||||
});
|
||||
};
|
||||
const removeSection = (si: number) =>
|
||||
onChange({ ...config, sections: sections.filter((_, i) => i !== si) });
|
||||
const addSection = () =>
|
||||
onChange({
|
||||
...config,
|
||||
sections: [...sections, { heading: "", links: [] }],
|
||||
});
|
||||
|
||||
const updateLink = (si: number, li: number, patch: Partial<LinkTreeLink>) => {
|
||||
const newSections = sections.map((s, i) => {
|
||||
if (i !== si) return s;
|
||||
return {
|
||||
...s,
|
||||
links: s.links.map((l, j) => (j === li ? { ...l, ...patch } : l)),
|
||||
};
|
||||
});
|
||||
onChange({ ...config, sections: newSections });
|
||||
};
|
||||
const removeLink = (si: number, li: number) => {
|
||||
const newSections = sections.map((s, i) =>
|
||||
i !== si ? s : { ...s, links: s.links.filter((_, j) => j !== li) },
|
||||
);
|
||||
onChange({ ...config, sections: newSections });
|
||||
};
|
||||
const addLink = (si: number) => {
|
||||
const newSections = sections.map((s, i) =>
|
||||
i !== si ? s : { ...s, links: [...s.links, { label: "", url: "" }] },
|
||||
);
|
||||
onChange({ ...config, sections: newSections });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 max-h-64 overflow-y-auto pr-1">
|
||||
{sections.map((section, si) => (
|
||||
<div
|
||||
key={si}
|
||||
className="flex flex-col gap-1 border border-border/40 p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={section.heading}
|
||||
onChange={(e) => updateSection(si, { heading: e.target.value })}
|
||||
placeholder={t("homepage.sectionHeading")}
|
||||
className="h-6 text-xs flex-1 font-semibold"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeSection(si)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 pl-2">
|
||||
{section.links.map((link, li) => (
|
||||
<div key={li} className="flex items-center gap-1">
|
||||
<Input
|
||||
value={link.label}
|
||||
onChange={(e) =>
|
||||
updateLink(si, li, { label: e.target.value })
|
||||
}
|
||||
placeholder={t("homepage.linkLabel")}
|
||||
className="h-6 text-[10px] flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={link.url}
|
||||
onChange={(e) =>
|
||||
updateLink(si, li, { url: e.target.value })
|
||||
}
|
||||
placeholder="https://..."
|
||||
className="h-6 text-[10px] flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeLink(si, li)}
|
||||
className="p-0.5 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => addLink(si)}
|
||||
className="flex items-center gap-0.5 text-[9px] text-accent-brand hover:opacity-80 mt-0.5 self-start"
|
||||
>
|
||||
<Plus size={9} /> {t("homepage.addLink")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addSection}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addSection")}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={compact}
|
||||
onChange={(e) => onChange({ ...config, compact: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.compactMode")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import type {
|
||||
MarkdownNotesConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function MarkdownNotesEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<MarkdownNotesConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.content")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={config.content}
|
||||
onChange={(e) => onChange({ ...config, content: e.target.value })}
|
||||
className="text-sm min-h-[120px] resize-none rounded-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.renderMarkdown}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, renderMarkdown: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.renderMarkdown")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.backgroundColor")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.backgroundColor ?? "#1e1e2e"}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, backgroundColor: e.target.value })
|
||||
}
|
||||
className="w-8 h-8 border border-border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={config.backgroundColor ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
backgroundColor: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="#1e1e2e"
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
{config.backgroundColor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ ...config, backgroundColor: undefined })
|
||||
}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
{t("homepage.clearColor")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
MetricsChartConfig,
|
||||
MetricsChartMetric,
|
||||
MetricsChartRange,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
|
||||
const METRICS: { id: MetricsChartMetric; label: string }[] = [
|
||||
{ id: "cpu", label: "CPU" },
|
||||
{ id: "memory", label: "Memory" },
|
||||
{ id: "disk", label: "Disk" },
|
||||
{ id: "net_rx", label: "RX MB/s" },
|
||||
{ id: "net_tx", label: "TX MB/s" },
|
||||
];
|
||||
|
||||
const RANGES: { id: MetricsChartRange; label: string }[] = [
|
||||
{ id: "15m", label: "15m" },
|
||||
{ id: "1h", label: "1h" },
|
||||
{ id: "6h", label: "6h" },
|
||||
{ id: "24h", label: "24h" },
|
||||
];
|
||||
|
||||
export function MetricsChartEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<MetricsChartConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<select
|
||||
value={config.hostId}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, hostId: Number(e.target.value) })
|
||||
}
|
||||
className="h-8 text-xs bg-background border border-border px-2 text-foreground"
|
||||
>
|
||||
<option value={0}>{t("homepage.selectHost")}</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.chartMetric")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{METRICS.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, metric: m.id })}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.metric === m.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.metricRange")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, range: r.id })}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.range === r.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showCurrentValue}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showCurrentValue: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showCurrentValue")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import type { NotesConfig, WidgetEditFormProps } from "@/types/homepage-types";
|
||||
|
||||
export function NotesEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<NotesConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.content")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={config.content}
|
||||
onChange={(e) => onChange({ ...config, content: e.target.value })}
|
||||
placeholder="Write your notes here..."
|
||||
className="text-sm min-h-[120px] resize-none rounded-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import type {
|
||||
PingStatusConfig,
|
||||
PingUrl,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function PingStatusEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<PingStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { urls, refreshInterval, showLatency } = config;
|
||||
|
||||
const updateUrl = (i: number, patch: Partial<PingUrl>) => {
|
||||
onChange({
|
||||
...config,
|
||||
urls: urls.map((u, idx) => (idx === i ? { ...u, ...patch } : u)),
|
||||
});
|
||||
};
|
||||
const removeUrl = (i: number) =>
|
||||
onChange({ ...config, urls: urls.filter((_, idx) => idx !== i) });
|
||||
const addUrl = () =>
|
||||
onChange({ ...config, urls: [...urls, { label: "", url: "" }] });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 max-h-48 overflow-y-auto pr-1">
|
||||
{urls.map((u, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={u.label}
|
||||
onChange={(e) => updateUrl(i, { label: e.target.value })}
|
||||
placeholder={t("homepage.pingLabel")}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={u.url}
|
||||
onChange={(e) => updateUrl(i, { url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeUrl(i)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addUrl}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addPingUrl")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.refreshInterval")} ({t("homepage.seconds")})
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={3600}
|
||||
value={refreshInterval}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
refreshInterval: Math.max(10, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLatency}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showLatency: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showLatency")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
QuickConnectConfig,
|
||||
QuickConnectType,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
|
||||
const ALL_TYPES: QuickConnectType[] = [
|
||||
"terminal",
|
||||
"files",
|
||||
"docker",
|
||||
"tunnel",
|
||||
"host-metrics",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"telnet",
|
||||
];
|
||||
|
||||
export function QuickConnectEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<QuickConnectConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then(setHosts)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleHost = (id: number) => {
|
||||
const current = config.hostIds;
|
||||
const ids = current.includes(id)
|
||||
? current.filter((x) => x !== id)
|
||||
: [...current, id];
|
||||
onChange({ ...config, hostIds: ids });
|
||||
};
|
||||
|
||||
const toggleType = (type: QuickConnectType) => {
|
||||
const types = config.connectionTypes.includes(type)
|
||||
? config.connectionTypes.filter((x) => x !== type)
|
||||
: [...config.connectionTypes, type];
|
||||
onChange({ ...config, connectionTypes: types });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.selectHosts")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1 max-h-36 overflow-y-auto border border-border/30 p-2">
|
||||
{hosts.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{t("homepage.noHosts")}
|
||||
</span>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<label
|
||||
key={h.id}
|
||||
className="flex items-center gap-2 text-xs cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hostIds.includes(h.id)}
|
||||
onChange={() => toggleHost(h.id)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
<span className="truncate">{h.name || h.ip}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.hostGridAllHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.connectionTypes")}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{ALL_TYPES.map((type) => (
|
||||
<label
|
||||
key={type}
|
||||
className="flex items-center gap-2 text-xs cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.connectionTypes.includes(type)}
|
||||
onChange={() => toggleType(type)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t(`homepage.connType_${type}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.layout")}
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{(["list", "grid"] as const).map((l) => (
|
||||
<label
|
||||
key={l}
|
||||
className="flex items-center gap-1.5 text-xs cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="layout"
|
||||
value={l}
|
||||
checked={config.layout === l}
|
||||
onChange={() => onChange({ ...config, layout: l })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{l === "list"
|
||||
? t("homepage.listLayout")
|
||||
: t("homepage.gridLayout")}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showStatus}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showStatus: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showStatus")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
RecentActivityConfig,
|
||||
ActivityType,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
const ALL_TYPES: ActivityType[] = [
|
||||
"terminal",
|
||||
"file_manager",
|
||||
"docker",
|
||||
"tunnel",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"telnet",
|
||||
];
|
||||
|
||||
export function RecentActivityEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<RecentActivityConfig>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleType = (type: ActivityType) => {
|
||||
const next = config.filterTypes.includes(type)
|
||||
? config.filterTypes.filter((x) => x !== type)
|
||||
: [...config.filterTypes, type];
|
||||
onChange({ ...config, filterTypes: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.maxItems}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: Math.max(1, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.filterActivityTypes")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ALL_TYPES.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => toggleType(type)}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors ${
|
||||
config.filterTypes.includes(type) ||
|
||||
config.filterTypes.length === 0
|
||||
? "bg-accent-brand border-accent-brand text-white"
|
||||
: "border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{type.replace("_", " ")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{t("homepage.filterTypesHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showTimestamp}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showTimestamp: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showTimestamp")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type {
|
||||
RssFeedConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function RssFeedEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<RssFeedConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.feedUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.feedUrl}
|
||||
onChange={(e) => onChange({ ...config, feedUrl: e.target.value })}
|
||||
placeholder="https://example.com/feed.xml"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.maxItems}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: Math.max(1, Math.min(50, Number(e.target.value))),
|
||||
})
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.showDescription")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.showDescription}
|
||||
onCheckedChange={(v) => onChange({ ...config, showDescription: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
SearchBarConfig,
|
||||
SearchEngine,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
const ENGINES: { id: SearchEngine; label: string }[] = [
|
||||
{ id: "google", label: "Google" },
|
||||
{ id: "duckduckgo", label: "DuckDuckGo" },
|
||||
{ id: "bing", label: "Bing" },
|
||||
{ id: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
export function SearchBarEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SearchBarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.searchEngine")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ENGINES.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, engine: e.id })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors ${config.engine === e.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{e.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.engine === "custom" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.customSearchUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.customUrl ?? ""}
|
||||
onChange={(e) => onChange({ ...config, customUrl: e.target.value })}
|
||||
placeholder="https://example.com/search?q={q}"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.searchPlaceholderLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.placeholder ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, placeholder: e.target.value || undefined })
|
||||
}
|
||||
placeholder={t("homepage.searchPlaceholderHint")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.openInNewTab}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, openInNewTab: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.openInNewTab")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import type {
|
||||
SearchLinksConfig,
|
||||
SearchLinkShortcut,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function SearchLinksEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SearchLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { shortcuts } = config;
|
||||
|
||||
const update = (i: number, patch: Partial<SearchLinkShortcut>) => {
|
||||
onChange({
|
||||
...config,
|
||||
shortcuts: shortcuts.map((s, idx) =>
|
||||
idx === i ? { ...s, ...patch } : s,
|
||||
),
|
||||
});
|
||||
};
|
||||
const remove = (i: number) =>
|
||||
onChange({ ...config, shortcuts: shortcuts.filter((_, idx) => idx !== i) });
|
||||
const add = () =>
|
||||
onChange({
|
||||
...config,
|
||||
shortcuts: [...shortcuts, { label: "", queryTemplate: "" }],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 max-h-56 overflow-y-auto pr-1">
|
||||
{shortcuts.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-1 border border-border/40 p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={s.icon ?? ""}
|
||||
onChange={(e) =>
|
||||
update(i, { icon: e.target.value || undefined })
|
||||
}
|
||||
placeholder="🔍"
|
||||
className="h-6 text-xs w-10 text-center"
|
||||
/>
|
||||
<Input
|
||||
value={s.label}
|
||||
onChange={(e) => update(i, { label: e.target.value })}
|
||||
placeholder={t("homepage.linkLabel")}
|
||||
className="h-6 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => remove(i)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
value={s.queryTemplate}
|
||||
onChange={(e) => update(i, { queryTemplate: e.target.value })}
|
||||
placeholder="https://example.com/search?q={q}"
|
||||
className="h-6 text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={add}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addSearchShortcut")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import type {
|
||||
ServiceGridConfig,
|
||||
ServiceGridItem,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function ServiceGridEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ServiceGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { services, columns, showLabels, iconSize } = config;
|
||||
|
||||
const updateItem = (i: number, patch: Partial<ServiceGridItem>) => {
|
||||
onChange({
|
||||
...config,
|
||||
services: services.map((s, idx) => (idx === i ? { ...s, ...patch } : s)),
|
||||
});
|
||||
};
|
||||
const removeItem = (i: number) =>
|
||||
onChange({ ...config, services: services.filter((_, idx) => idx !== i) });
|
||||
const addItem = () =>
|
||||
onChange({ ...config, services: [...services, { label: "", url: "" }] });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{services.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={s.label}
|
||||
onChange={(e) => updateItem(i, { label: e.target.value })}
|
||||
placeholder={t("homepage.linkLabel")}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={s.url}
|
||||
onChange={(e) => updateItem(i, { url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addItem}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addService")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.columns")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{([2, 3, 4] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, columns: c })}
|
||||
className={`px-3 py-0.5 text-[10px] border transition-colors ${columns === c ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.iconSize")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["sm", "md", "lg"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, iconSize: s })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors uppercase ${iconSize === s ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLabels}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showLabels: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showLabels")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type {
|
||||
ServiceLinkConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceLinkEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ServiceLinkConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const accent = getAccentColor();
|
||||
const showImage = config.showImage ?? true;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.url")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.url}
|
||||
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.description")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.description ?? ""}
|
||||
onChange={(e) => onChange({ ...config, description: e.target.value })}
|
||||
placeholder="My service"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.showImage")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={showImage}
|
||||
onCheckedChange={(v) => onChange({ ...config, showImage: v })}
|
||||
/>
|
||||
</div>
|
||||
{showImage && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.imageUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.imageUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, imageUrl: e.target.value || undefined })
|
||||
}
|
||||
placeholder="https://example.com/logo.png"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.imageUrlHint")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.color")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.accentColor || accent}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, accentColor: e.target.value })
|
||||
}
|
||||
className="w-8 h-8 border border-border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={config.accentColor ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, accentColor: e.target.value || undefined })
|
||||
}
|
||||
placeholder={accent}
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
|
||||
interface SingleHostEditFormProps {
|
||||
hostId: number;
|
||||
onChange: (hostId: number) => void;
|
||||
filter?: (host: SSHHostWithStatus) => boolean;
|
||||
}
|
||||
|
||||
export function SingleHostEditForm({
|
||||
hostId,
|
||||
onChange,
|
||||
filter,
|
||||
}: SingleHostEditFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((all) => setHosts(filter ? all.filter(filter) : all))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<select
|
||||
value={hostId || ""}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="h-8 text-xs border border-border bg-background px-2"
|
||||
>
|
||||
<option value="">{t("homepage.selectHost")}</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name || h.ip}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
SshQuickConnectConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
|
||||
export function SshQuickConnectEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SshQuickConnectConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleHost = (id: number) => {
|
||||
const next = config.hostIds.includes(id)
|
||||
? config.hostIds.filter((x) => x !== id)
|
||||
: [...config.hostIds, id];
|
||||
onChange({ ...config, hostIds: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-0.5 max-h-40 overflow-y-auto border border-border/40 p-1">
|
||||
{hosts.map((h) => (
|
||||
<label
|
||||
key={h.id}
|
||||
className="flex items-center gap-2 text-[10px] cursor-pointer px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hostIds.includes(h.id)}
|
||||
onChange={() => toggleHost(h.id)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{h.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{t("homepage.hostGridAllHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.connectionType")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["terminal", "files", "docker"] as const).map((ct) => (
|
||||
<button
|
||||
key={ct}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, connectionType: ct })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors capitalize ${config.connectionType === ct ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{ct}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.layout")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["list", "grid"] as const).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, layout: l })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors capitalize ${config.layout === l ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showStatus}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showStatus: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showStatus")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
SshTerminalConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { SingleHostEditForm } from "./SingleHostEditForm";
|
||||
|
||||
export function SshTerminalEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SshTerminalConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SingleHostEditForm
|
||||
hostId={config.hostId}
|
||||
onChange={(hostId) => onChange({ ...config, hostId })}
|
||||
filter={(h) => !!(h.enableSsh && h.enableTerminal)}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.autoConnect}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, autoConnect: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.sshTerminalAutoConnect")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
SystemOverviewConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function SystemOverviewEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SystemOverviewConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showVersion}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showVersion: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.overviewVersion")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showDbHealth}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showDbHealth: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.overviewDatabase")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showUptime}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showUptime: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.overviewUptime")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
TermixUptimeConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function TermixUptimeEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<TermixUptimeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showDetailed}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showDetailed: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showSeconds")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import type {
|
||||
TextBannerConfig,
|
||||
TextBannerFontSize,
|
||||
TextBannerAlign,
|
||||
TextBannerWeight,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
const SIZES: { id: TextBannerFontSize; label: string }[] = [
|
||||
{ id: "sm", label: "SM" },
|
||||
{ id: "md", label: "MD" },
|
||||
{ id: "lg", label: "LG" },
|
||||
{ id: "xl", label: "XL" },
|
||||
];
|
||||
|
||||
const ALIGNS: { id: TextBannerAlign; label: string }[] = [
|
||||
{ id: "left", label: "Left" },
|
||||
{ id: "center", label: "Center" },
|
||||
{ id: "right", label: "Right" },
|
||||
];
|
||||
|
||||
const WEIGHTS: { id: TextBannerWeight; label: string }[] = [
|
||||
{ id: "normal", label: "Normal" },
|
||||
{ id: "semibold", label: "Semibold" },
|
||||
{ id: "bold", label: "Bold" },
|
||||
];
|
||||
|
||||
export function TextBannerEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<TextBannerConfig>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ButtonGroup = <T extends string>({
|
||||
opts,
|
||||
value,
|
||||
onSelect,
|
||||
}: {
|
||||
opts: { id: T; label: string }[];
|
||||
value: T;
|
||||
onSelect: (v: T) => void;
|
||||
}) => (
|
||||
<div className="flex gap-1">
|
||||
{opts.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(o.id)}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors ${value === o.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.content")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={config.text}
|
||||
onChange={(e) => onChange({ ...config, text: e.target.value })}
|
||||
rows={2}
|
||||
className="text-sm min-h-[60px] resize-none rounded-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.fontSize")}
|
||||
</label>
|
||||
<ButtonGroup
|
||||
opts={SIZES}
|
||||
value={config.fontSize}
|
||||
onSelect={(v) => onChange({ ...config, fontSize: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.textAlign")}
|
||||
</label>
|
||||
<ButtonGroup
|
||||
opts={ALIGNS}
|
||||
value={config.textAlign}
|
||||
onSelect={(v) => onChange({ ...config, textAlign: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.fontWeight")}
|
||||
</label>
|
||||
<ButtonGroup
|
||||
opts={WEIGHTS}
|
||||
value={config.fontWeight}
|
||||
onSelect={(v) => onChange({ ...config, fontWeight: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.backgroundColor")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.backgroundColor ?? "#1e1e2e"}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, backgroundColor: e.target.value })
|
||||
}
|
||||
className="w-8 h-8 border border-border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={config.backgroundColor ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
backgroundColor: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="#1e1e2e"
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
{config.backgroundColor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ ...config, backgroundColor: undefined })
|
||||
}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
{t("homepage.clearColor")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
TunnelWidgetConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { SingleHostEditForm } from "./SingleHostEditForm";
|
||||
|
||||
export function TunnelWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<TunnelWidgetConfig>) {
|
||||
return (
|
||||
<SingleHostEditForm
|
||||
hostId={config.hostId}
|
||||
onChange={(hostId) => onChange({ ...config, hostId })}
|
||||
filter={(h) => !!(h.enableSsh && h.enableTunnel)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type {
|
||||
WeatherConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function WeatherEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<WeatherConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.location")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.location}
|
||||
onChange={(e) => onChange({ ...config, location: e.target.value })}
|
||||
placeholder="New York, London, Tokyo..."
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.temperatureUnit")}
|
||||
</label>
|
||||
<div className="flex gap-0 border border-border">
|
||||
{(["C", "F"] as const).map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, unit: u })}
|
||||
className={`flex-1 h-8 text-sm transition-colors ${
|
||||
config.unit === u
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
°{u}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.showForecast")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.showForecast}
|
||||
onCheckedChange={(v) => onChange({ ...config, showForecast: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
CanvasWidget,
|
||||
ServiceLinkConfig,
|
||||
FolderConfig,
|
||||
NotesConfig,
|
||||
BookmarkListConfig,
|
||||
ClockConfig,
|
||||
HostStatusConfig,
|
||||
WeatherConfig,
|
||||
IframeConfig,
|
||||
RssFeedConfig,
|
||||
MetricsChartConfig,
|
||||
HostGridConfig,
|
||||
AlertFeedConfig,
|
||||
PingStatusConfig,
|
||||
RecentActivityConfig,
|
||||
TermixUptimeConfig,
|
||||
SystemOverviewConfig,
|
||||
SshTerminalConfig,
|
||||
QuickConnectConfig,
|
||||
FileManagerWidgetConfig,
|
||||
DockerWidgetConfig,
|
||||
TunnelWidgetConfig,
|
||||
CalendarConfig,
|
||||
CountdownConfig,
|
||||
SearchBarConfig,
|
||||
TextBannerConfig,
|
||||
ImageWidgetConfig,
|
||||
MarkdownNotesConfig,
|
||||
CustomApiConfig,
|
||||
ServiceGridConfig,
|
||||
DashboardLinksConfig,
|
||||
SearchLinksConfig,
|
||||
LinkTreeConfig,
|
||||
} from "@/types/homepage-types";
|
||||
import { ServiceLinkEditForm } from "./ServiceLinkEditForm";
|
||||
import { FolderEditForm } from "./FolderEditForm";
|
||||
import { NotesEditForm } from "./NotesEditForm";
|
||||
import { BookmarkListEditForm } from "./BookmarkListEditForm";
|
||||
import { ClockEditForm } from "./ClockEditForm";
|
||||
import { HostStatusEditForm } from "./HostStatusEditForm";
|
||||
import { WeatherEditForm } from "./WeatherEditForm";
|
||||
import { IframeEditForm } from "./IframeEditForm";
|
||||
import { RssFeedEditForm } from "./RssFeedEditForm";
|
||||
import { MetricsChartEditForm } from "./MetricsChartEditForm";
|
||||
import { HostGridEditForm } from "./HostGridEditForm";
|
||||
import { AlertFeedEditForm } from "./AlertFeedEditForm";
|
||||
import { PingStatusEditForm } from "./PingStatusEditForm";
|
||||
import { RecentActivityEditForm } from "./RecentActivityEditForm";
|
||||
import { TermixUptimeEditForm } from "./TermixUptimeEditForm";
|
||||
import { SystemOverviewEditForm } from "./SystemOverviewEditForm";
|
||||
import { SshTerminalEditForm } from "./SshTerminalEditForm";
|
||||
import { QuickConnectEditForm } from "./QuickConnectEditForm";
|
||||
import { FileManagerWidgetEditForm } from "./FileManagerWidgetEditForm";
|
||||
import { DockerWidgetEditForm } from "./DockerWidgetEditForm";
|
||||
import { TunnelWidgetEditForm } from "./TunnelWidgetEditForm";
|
||||
import { CalendarEditForm } from "./CalendarEditForm";
|
||||
import { CountdownEditForm } from "./CountdownEditForm";
|
||||
import { SearchBarEditForm } from "./SearchBarEditForm";
|
||||
import { TextBannerEditForm } from "./TextBannerEditForm";
|
||||
import { ImageWidgetEditForm } from "./ImageWidgetEditForm";
|
||||
import { MarkdownNotesEditForm } from "./MarkdownNotesEditForm";
|
||||
import { CustomApiEditForm } from "./CustomApiEditForm";
|
||||
import { ServiceGridEditForm } from "./ServiceGridEditForm";
|
||||
import { DashboardLinksEditForm } from "./DashboardLinksEditForm";
|
||||
import { SearchLinksEditForm } from "./SearchLinksEditForm";
|
||||
import { LinkTreeEditForm } from "./LinkTreeEditForm";
|
||||
|
||||
interface WidgetEditDialogProps {
|
||||
widget: CanvasWidget | null;
|
||||
onSave: (
|
||||
id: number,
|
||||
title: string | null,
|
||||
config: Record<string, unknown>,
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WidgetEditDialog({
|
||||
widget,
|
||||
onSave,
|
||||
onClose,
|
||||
}: WidgetEditDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [title, setTitle] = useState(widget?.title ?? "");
|
||||
const [config, setConfig] = useState<Record<string, unknown>>(
|
||||
widget?.config ?? {},
|
||||
);
|
||||
|
||||
if (!widget) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(widget.id, title || null, config);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderForm = () => {
|
||||
switch (widget.typeId) {
|
||||
case "service_link":
|
||||
return (
|
||||
<ServiceLinkEditForm
|
||||
config={config as ServiceLinkConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "folder":
|
||||
return (
|
||||
<FolderEditForm
|
||||
config={config as FolderConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "notes":
|
||||
return (
|
||||
<NotesEditForm
|
||||
config={config as NotesConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "bookmark_list":
|
||||
return (
|
||||
<BookmarkListEditForm
|
||||
config={config as BookmarkListConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "clock":
|
||||
return (
|
||||
<ClockEditForm
|
||||
config={config as ClockConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "host_status":
|
||||
return (
|
||||
<HostStatusEditForm
|
||||
config={config as HostStatusConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "weather":
|
||||
return (
|
||||
<WeatherEditForm
|
||||
config={config as WeatherConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "iframe_embed":
|
||||
return (
|
||||
<IframeEditForm
|
||||
config={config as IframeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "rss_feed":
|
||||
return (
|
||||
<RssFeedEditForm
|
||||
config={config as RssFeedConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "metrics_chart":
|
||||
return (
|
||||
<MetricsChartEditForm
|
||||
config={config as MetricsChartConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "host_grid":
|
||||
return (
|
||||
<HostGridEditForm
|
||||
config={config as HostGridConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "alert_feed":
|
||||
return (
|
||||
<AlertFeedEditForm
|
||||
config={config as AlertFeedConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "ping_status":
|
||||
return (
|
||||
<PingStatusEditForm
|
||||
config={config as PingStatusConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "recent_activity":
|
||||
return (
|
||||
<RecentActivityEditForm
|
||||
config={config as RecentActivityConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "termix_uptime":
|
||||
return (
|
||||
<TermixUptimeEditForm
|
||||
config={config as TermixUptimeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "system_overview":
|
||||
return (
|
||||
<SystemOverviewEditForm
|
||||
config={config as SystemOverviewConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "ssh_terminal":
|
||||
return (
|
||||
<SshTerminalEditForm
|
||||
config={config as SshTerminalConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "quick_connect":
|
||||
return (
|
||||
<QuickConnectEditForm
|
||||
config={config as QuickConnectConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "file_manager_widget":
|
||||
return (
|
||||
<FileManagerWidgetEditForm
|
||||
config={config as FileManagerWidgetConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "docker_widget":
|
||||
return (
|
||||
<DockerWidgetEditForm
|
||||
config={config as DockerWidgetConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "tunnel_widget":
|
||||
return (
|
||||
<TunnelWidgetEditForm
|
||||
config={config as TunnelWidgetConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "calendar":
|
||||
return (
|
||||
<CalendarEditForm
|
||||
config={config as CalendarConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "countdown":
|
||||
return (
|
||||
<CountdownEditForm
|
||||
config={config as CountdownConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "search_bar":
|
||||
return (
|
||||
<SearchBarEditForm
|
||||
config={config as SearchBarConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "text_banner":
|
||||
return (
|
||||
<TextBannerEditForm
|
||||
config={config as TextBannerConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "image_widget":
|
||||
return (
|
||||
<ImageWidgetEditForm
|
||||
config={config as ImageWidgetConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "markdown_notes":
|
||||
return (
|
||||
<MarkdownNotesEditForm
|
||||
config={config as MarkdownNotesConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "custom_api":
|
||||
return (
|
||||
<CustomApiEditForm
|
||||
config={config as CustomApiConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "service_grid":
|
||||
return (
|
||||
<ServiceGridEditForm
|
||||
config={config as ServiceGridConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "dashboard_links":
|
||||
return (
|
||||
<DashboardLinksEditForm
|
||||
config={config as DashboardLinksConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "search_links":
|
||||
return (
|
||||
<SearchLinksEditForm
|
||||
config={config as SearchLinksConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "link_tree":
|
||||
return (
|
||||
<LinkTreeEditForm
|
||||
config={config as LinkTreeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!widget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-sm"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("homepage.editWidget")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.title_label")}
|
||||
</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("homepage.widgetTitlePlaceholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{renderForm()}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t("homepage.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
{t("homepage.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Lock,
|
||||
LockOpen,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
PlusCircle,
|
||||
} from "lucide-react";
|
||||
import { MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from "@/types/homepage-types";
|
||||
import { zoomAroundPoint } from "../canvas/canvasGeometry";
|
||||
|
||||
interface HomepageToolbarProps {
|
||||
zoom: number;
|
||||
isLocked: boolean;
|
||||
pan: { x: number; y: number };
|
||||
containerSize: { w: number; h: number };
|
||||
onZoomChange: (zoom: number, pan: { x: number; y: number }) => void;
|
||||
onLockToggle: () => void;
|
||||
onResetView: () => void;
|
||||
onAddWidget: (anchorRect: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function ToolButton({
|
||||
onClick,
|
||||
title,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted/60 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageToolbar({
|
||||
zoom,
|
||||
isLocked,
|
||||
pan,
|
||||
containerSize,
|
||||
onZoomChange,
|
||||
onLockToggle,
|
||||
onResetView,
|
||||
onAddWidget,
|
||||
}: HomepageToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const cx = containerSize.w / 2;
|
||||
const cy = containerSize.h / 2;
|
||||
|
||||
const zoomIn = () => {
|
||||
const next = Math.min(MAX_ZOOM, parseFloat((zoom + ZOOM_STEP).toFixed(1)));
|
||||
onZoomChange(next, zoomAroundPoint(cx, cy, pan, zoom, next));
|
||||
};
|
||||
const zoomOut = () => {
|
||||
const next = Math.max(MIN_ZOOM, parseFloat((zoom - ZOOM_STEP).toFixed(1)));
|
||||
onZoomChange(next, zoomAroundPoint(cx, cy, pan, zoom, next));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 right-4 z-30 flex items-center gap-0 bg-card border border-border shadow-lg">
|
||||
<ToolButton
|
||||
onClick={(e) => onAddWidget(e.currentTarget.getBoundingClientRect())}
|
||||
title={t("homepage.addWidget")}
|
||||
disabled={isLocked}
|
||||
>
|
||||
<PlusCircle size={15} className={isLocked ? "" : "text-accent-brand"} />
|
||||
</ToolButton>
|
||||
|
||||
<div className="w-px h-5 bg-border" />
|
||||
|
||||
<ToolButton
|
||||
onClick={zoomOut}
|
||||
title={t("homepage.zoomOut")}
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
>
|
||||
<Minus size={13} />
|
||||
</ToolButton>
|
||||
|
||||
<span className="text-[11px] font-mono text-foreground tabular-nums w-10 text-center select-none">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
|
||||
<ToolButton
|
||||
onClick={zoomIn}
|
||||
title={t("homepage.zoomIn")}
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</ToolButton>
|
||||
|
||||
<div className="w-px h-5 bg-border" />
|
||||
|
||||
<ToolButton onClick={onResetView} title={t("homepage.resetView")}>
|
||||
<RotateCcw size={13} />
|
||||
</ToolButton>
|
||||
|
||||
<ToolButton
|
||||
onClick={onLockToggle}
|
||||
title={isLocked ? t("homepage.unlockLayout") : t("homepage.lockLayout")}
|
||||
>
|
||||
{isLocked ? (
|
||||
<Lock size={13} className="text-accent-brand" />
|
||||
) : (
|
||||
<LockOpen size={13} />
|
||||
)}
|
||||
</ToolButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
AlertFeedConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getAlertFirings, acknowledgeAlertFiring } from "@/api/alerts-api";
|
||||
import type { AlertFiring } from "@/api/alerts-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function relativeTime(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function SeverityIcon({ severity }: { severity: AlertFiring["severity"] }) {
|
||||
if (severity === "critical")
|
||||
return <AlertCircle size={11} className="shrink-0 text-red-500" />;
|
||||
if (severity === "warning")
|
||||
return <AlertTriangle size={11} className="shrink-0 text-amber-500" />;
|
||||
return <Info size={11} className="shrink-0 text-blue-400" />;
|
||||
}
|
||||
|
||||
function AlertFeedWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<AlertFeedConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { maxItems, showAcknowledged } = config;
|
||||
const [firings, setFirings] = useState<AlertFiring[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const data = await getAlertFirings({ limit: maxItems * 2 });
|
||||
const filtered = showAcknowledged
|
||||
? data
|
||||
: data.filter((f) => !f.acknowledged);
|
||||
setFirings(filtered.slice(0, maxItems));
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, 30_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [maxItems, showAcknowledged]);
|
||||
|
||||
const handleAck = async (id: number) => {
|
||||
try {
|
||||
await acknowledgeAlertFiring(id);
|
||||
setFirings((prev) =>
|
||||
prev.map((f) => (f.id === id ? { ...f, acknowledged: true } : f)),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (firings.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||
<CheckCircle size={20} className="text-green-500" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("homepage.allClear")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || t("homepage.widgetAlertFeedName")}
|
||||
icon={<Bell size={11} />}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{firings.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className={`flex items-start gap-2 px-2 py-1.5 border-b border-border/30 ${f.acknowledged ? "opacity-50" : ""}`}
|
||||
>
|
||||
<SeverityIcon severity={f.severity} />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[10px] font-semibold text-foreground truncate">
|
||||
{f.ruleName ?? f.message}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{f.hostName} · {relativeTime(f.firedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{!f.acknowledged && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAck(f.id);
|
||||
}}
|
||||
className="p-0.5 text-muted-foreground hover:text-green-500 transition-colors shrink-0"
|
||||
title={t("homepage.acknowledgeAlert")}
|
||||
>
|
||||
<Check size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<AlertFeedConfig>({
|
||||
id: "alert_feed",
|
||||
name: "Alert Feed",
|
||||
description: "Live feed of active alert firings",
|
||||
category: "monitoring",
|
||||
icon: <Bell size={14} />,
|
||||
defaultConfig: { maxItems: 10, showAcknowledged: false },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: AlertFeedWidget,
|
||||
});
|
||||
|
||||
export { AlertFeedWidget };
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Bookmark, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
BookmarkListConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
function BookmarkListWidget({
|
||||
config,
|
||||
widget,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<BookmarkListConfig>) {
|
||||
const { links } = config;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden">
|
||||
{widget.title && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border shrink-0">
|
||||
<Bookmark size={11} className="text-accent-brand shrink-0" />
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
{widget.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto p-3 gap-1 flex flex-col">
|
||||
{links.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
No bookmarks
|
||||
</span>
|
||||
)}
|
||||
{links.map((link, i) =>
|
||||
isReadOnly ? (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 text-xs text-foreground py-1"
|
||||
>
|
||||
<Bookmark size={10} className="text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{link.label || link.url}</span>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs text-foreground hover:text-primary py-1 no-underline group"
|
||||
>
|
||||
<Bookmark size={10} className="text-muted-foreground shrink-0" />
|
||||
<span className="truncate flex-1">{link.label || link.url}</span>
|
||||
<ExternalLink
|
||||
size={8}
|
||||
className="text-muted-foreground/0 group-hover:text-muted-foreground/60 shrink-0"
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<BookmarkListConfig>({
|
||||
id: "bookmark_list",
|
||||
name: "Bookmarks",
|
||||
description: "A list of quick links",
|
||||
category: "links",
|
||||
icon: <Bookmark size={14} />,
|
||||
defaultConfig: { links: [] },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 10 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: BookmarkListWidget,
|
||||
});
|
||||
|
||||
export { BookmarkListWidget };
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
CalendarConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getMonthData(year: number, month: number, startOnMonday: boolean) {
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const offset = startOnMonday ? (firstDay === 0 ? 6 : firstDay - 1) : firstDay;
|
||||
return { daysInMonth, offset };
|
||||
}
|
||||
|
||||
function CalendarWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CalendarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { startOnMonday } = config;
|
||||
const [now, setNow] = useState(new Date());
|
||||
const [viewYear, setViewYear] = useState(now.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth());
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => setNow(new Date()), 60_000);
|
||||
return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const { daysInMonth, offset } = getMonthData(
|
||||
viewYear,
|
||||
viewMonth,
|
||||
startOnMonday,
|
||||
);
|
||||
const today = now;
|
||||
const isCurrentMonth =
|
||||
viewYear === today.getFullYear() && viewMonth === today.getMonth();
|
||||
|
||||
const dayNames = startOnMonday
|
||||
? [
|
||||
t("homepage.calMon"),
|
||||
t("homepage.calTue"),
|
||||
t("homepage.calWed"),
|
||||
t("homepage.calThu"),
|
||||
t("homepage.calFri"),
|
||||
t("homepage.calSat"),
|
||||
t("homepage.calSun"),
|
||||
]
|
||||
: [
|
||||
t("homepage.calSun"),
|
||||
t("homepage.calMon"),
|
||||
t("homepage.calTue"),
|
||||
t("homepage.calWed"),
|
||||
t("homepage.calThu"),
|
||||
t("homepage.calFri"),
|
||||
t("homepage.calSat"),
|
||||
];
|
||||
|
||||
const monthLabel = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(new Date(viewYear, viewMonth, 1));
|
||||
|
||||
const cells: (number | null)[] = [
|
||||
...Array(offset).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11);
|
||||
setViewYear((y) => y - 1);
|
||||
} else setViewMonth((m) => m - 1);
|
||||
};
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0);
|
||||
setViewYear((y) => y + 1);
|
||||
} else setViewMonth((m) => m + 1);
|
||||
};
|
||||
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden select-none">
|
||||
<WidgetTitle title={widget.title} icon={<CalendarDays size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</button>
|
||||
<span className="text-[10px] font-semibold text-foreground">
|
||||
{monthLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0 shrink-0">
|
||||
{dayNames.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="text-center text-[8px] font-semibold text-muted-foreground/70 uppercase py-0.5"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0 flex-1 content-start">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e-${i}`} />;
|
||||
const isToday = isCurrentMonth && day === today.getDate();
|
||||
return (
|
||||
<div
|
||||
key={`d-${day}`}
|
||||
className="flex items-center justify-center aspect-square"
|
||||
>
|
||||
<span
|
||||
className="text-[9px] font-medium w-5 h-5 flex items-center justify-center rounded-full"
|
||||
style={
|
||||
isToday
|
||||
? { background: accent, color: "#fff" }
|
||||
: { color: "var(--text-primary, currentColor)" }
|
||||
}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CalendarConfig>({
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
description: "A month calendar with today highlighted",
|
||||
category: "info",
|
||||
icon: <CalendarDays size={14} />,
|
||||
defaultConfig: { startOnMonday: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 9 },
|
||||
minSize: { w: GRID_SIZE * 7, h: GRID_SIZE * 7 },
|
||||
component: CalendarWidget,
|
||||
});
|
||||
|
||||
export { CalendarWidget };
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { ClockConfig, WidgetComponentProps } from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function ClockWidget({ widget, config }: WidgetComponentProps<ClockConfig>) {
|
||||
const { timezone, showSeconds, format } = config;
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...(showSeconds ? { second: "2-digit" } : {}),
|
||||
hour12: format === "12h",
|
||||
...(timezone ? { timeZone: timezone } : {}),
|
||||
};
|
||||
|
||||
const dateOpts: Intl.DateTimeFormatOptions = {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(timezone ? { timeZone: timezone } : {}),
|
||||
};
|
||||
|
||||
const timeStr = now.toLocaleTimeString(undefined, opts);
|
||||
const dateStr = now.toLocaleDateString(undefined, dateOpts);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden select-none">
|
||||
<WidgetTitle title={widget.title} icon={<Clock size={11} />} />
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-3 overflow-auto">
|
||||
<span className="text-2xl font-bold tabular-nums text-accent-brand">
|
||||
{timeStr}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{dateStr}</span>
|
||||
{timezone && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{timezone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ClockConfig>({
|
||||
id: "clock",
|
||||
name: "Clock",
|
||||
description: "A live clock with configurable timezone",
|
||||
category: "info",
|
||||
icon: <Clock size={14} />,
|
||||
defaultConfig: { showSeconds: true, format: "24h" },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 5 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: ClockWidget,
|
||||
});
|
||||
|
||||
export { ClockWidget };
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Timer } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
CountdownConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function decompose(ms: number): {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const days = Math.floor(total / 86400);
|
||||
const hours = Math.floor((total % 86400) / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
return { days, hours, minutes, seconds };
|
||||
}
|
||||
|
||||
function UnitBlock({ value, label }: { value: number; label: string }) {
|
||||
const accent = getAccentColor();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums leading-none"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{String(value).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountdownWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CountdownConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { targetDate, label, showDays, showHours } = config;
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
const [past, setPast] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const diff = new Date(targetDate).getTime() - Date.now();
|
||||
if (diff <= 0) {
|
||||
setRemaining(0);
|
||||
setPast(true);
|
||||
} else {
|
||||
setRemaining(diff);
|
||||
setPast(false);
|
||||
}
|
||||
};
|
||||
update();
|
||||
const iv = setInterval(update, 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [targetDate]);
|
||||
|
||||
const titleBar = (
|
||||
<WidgetTitle title={widget.title} icon={<Timer size={11} />} />
|
||||
);
|
||||
|
||||
if (!targetDate) {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex items-center justify-center flex-1 text-xs text-muted-foreground">
|
||||
{t("homepage.countdownNoDate")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (past) {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1">
|
||||
<Timer size={16} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("homepage.countdownPast")}
|
||||
</span>
|
||||
{label && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { days, hours, minutes, seconds } = decompose(remaining);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-2 p-2">
|
||||
{label && (
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||||
{showDays && (
|
||||
<UnitBlock value={days} label={t("homepage.countdownDays")} />
|
||||
)}
|
||||
{showHours && (
|
||||
<UnitBlock value={hours} label={t("homepage.countdownHours")} />
|
||||
)}
|
||||
<UnitBlock value={minutes} label={t("homepage.countdownMinutes")} />
|
||||
<UnitBlock value={seconds} label={t("homepage.countdownSeconds")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CountdownConfig>({
|
||||
id: "countdown",
|
||||
name: "Countdown",
|
||||
description: "Counts down to a target date and time",
|
||||
category: "info",
|
||||
icon: <Timer size={14} />,
|
||||
defaultConfig: { targetDate: "", label: "", showDays: true, showHours: true },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 5 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: CountdownWidget,
|
||||
});
|
||||
|
||||
export { CountdownWidget };
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Braces } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
CustomApiConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { homepageApi } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function resolvePath(obj: unknown, path: string): unknown {
|
||||
return path.split(".").reduce((acc: unknown, key) => {
|
||||
if (acc && typeof acc === "object")
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
return undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
function resolveArray(
|
||||
obj: unknown,
|
||||
path: string | undefined,
|
||||
): unknown[] | null {
|
||||
if (!path) {
|
||||
if (Array.isArray(obj)) return obj as unknown[];
|
||||
return null;
|
||||
}
|
||||
const val = resolvePath(obj, path);
|
||||
return Array.isArray(val) ? (val as unknown[]) : null;
|
||||
}
|
||||
|
||||
async function fetchProxy(url: string, ttl: number): Promise<unknown> {
|
||||
const res = await homepageApi.get("/proxy", { params: { url, ttl } });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function CustomApiWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CustomApiConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
url,
|
||||
displayField,
|
||||
label,
|
||||
unit,
|
||||
refreshInterval,
|
||||
displayMode,
|
||||
jsonPath,
|
||||
} = config;
|
||||
const [data, setData] = useState<unknown>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const interval = Math.max(10, refreshInterval || 60);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!url) return;
|
||||
try {
|
||||
const result = await fetchProxy(url, interval);
|
||||
setData(result);
|
||||
setError(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, interval * 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [url, interval]);
|
||||
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.customApiNoUrl")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-red-400">
|
||||
{t("homepage.customApiError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const titleBar = (
|
||||
<WidgetTitle title={widget.title} icon={<Braces size={11} />} />
|
||||
);
|
||||
|
||||
if (displayMode === "value") {
|
||||
const val = displayField ? resolvePath(data, displayField) : data;
|
||||
const display =
|
||||
typeof val === "object" ? JSON.stringify(val) : String(val ?? "—");
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-2">
|
||||
{label && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{unit && (
|
||||
<span className="text-[10px] text-muted-foreground">{unit}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayMode === "table") {
|
||||
const arr = resolveArray(data, jsonPath);
|
||||
if (!arr) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.customApiNotArray")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
<table className="w-full text-[9px]">
|
||||
<tbody>
|
||||
{arr.slice(0, 50).map((row, i) => {
|
||||
if (typeof row !== "object" || row === null) {
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="py-0.5 pr-2 text-foreground">
|
||||
{String(row)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr key={i} className="border-b border-border/30">
|
||||
{Object.entries(row as Record<string, unknown>)
|
||||
.slice(0, 4)
|
||||
.map(([k, v]) => (
|
||||
<td key={k} className="py-0.5 pr-2">
|
||||
<span className="text-muted-foreground">{k}: </span>
|
||||
<span className="text-foreground">
|
||||
{String(v ?? "")}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// json mode
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
<pre className="text-[9px] text-foreground whitespace-pre-wrap break-all leading-relaxed font-mono">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CustomApiConfig>({
|
||||
id: "custom_api",
|
||||
name: "Custom API",
|
||||
description: "Fetch and display data from any JSON API",
|
||||
category: "info",
|
||||
icon: <Braces size={14} />,
|
||||
defaultConfig: { url: "", refreshInterval: 60, displayMode: "value" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: CustomApiWidget,
|
||||
});
|
||||
|
||||
export { CustomApiWidget };
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Link, ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
DashboardLinksConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getServiceLinks } from "@/api/dashboard-api";
|
||||
import type { ServiceLink } from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getFaviconUrl(url: string): string {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?sz=32&domain_url=${encodeURIComponent(domain)}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function LinkTile({
|
||||
link,
|
||||
showIcons,
|
||||
isReadOnly,
|
||||
}: {
|
||||
link: ServiceLink;
|
||||
showIcons: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const faviconUrl = showIcons ? getFaviconUrl(link.url) : null;
|
||||
|
||||
const content = (
|
||||
<div className="group flex items-center gap-2 px-2 py-1.5 hover:bg-muted/40 transition-colors overflow-hidden border-b border-border/30">
|
||||
{showIcons &&
|
||||
(faviconUrl && !imgFailed ? (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="w-4 h-4 shrink-0 object-contain"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<ExternalLink size={12} className="shrink-0 text-muted-foreground" />
|
||||
))}
|
||||
<span className="text-[10px] font-medium text-foreground truncate flex-1">
|
||||
{link.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !link.url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardLinksWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<DashboardLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { showIcons, columns, maxItems } = config;
|
||||
const [links, setLinks] = useState<ServiceLink[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getServiceLinks()
|
||||
.then((data) => setLinks(maxItems ? data.slice(0, maxItems) : data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [maxItems]);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
if (links.length === 0)
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noDashboardLinks")}
|
||||
</div>
|
||||
);
|
||||
|
||||
const colClass =
|
||||
columns === 1
|
||||
? "grid-cols-1"
|
||||
: columns === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-3";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Link size={11} />} />
|
||||
<div className={`grid ${colClass} flex-1 overflow-auto content-start`}>
|
||||
{links.map((link) => (
|
||||
<LinkTile
|
||||
key={link.id}
|
||||
link={link}
|
||||
showIcons={showIcons}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<DashboardLinksConfig>({
|
||||
id: "dashboard_links",
|
||||
name: "Dashboard Links",
|
||||
description: "Shows your saved dashboard service links",
|
||||
category: "links",
|
||||
icon: <Link size={14} />,
|
||||
defaultConfig: { showIcons: true, columns: 1 },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: DashboardLinksWidget,
|
||||
});
|
||||
|
||||
export { DashboardLinksWidget };
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Container } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
DockerActivityConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getRecentActivity } from "@/api/dashboard-api";
|
||||
import type { RecentActivityItem } from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function relativeTime(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function DockerActivityWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<DockerActivityConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { maxItems, showHostName } = config;
|
||||
const [items, setItems] = useState<RecentActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const data = await getRecentActivity(maxItems * 5);
|
||||
setItems(data.filter((i) => i.type === "docker").slice(0, maxItems));
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, 30_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [maxItems]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noDockerActivity")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || t("homepage.widgetDockerActivityName")}
|
||||
icon={<Container size={11} />}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<Container size={10} className="shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
{showHostName && (
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{item.hostName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{relativeTime(item.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<DockerActivityConfig>({
|
||||
id: "docker_activity",
|
||||
name: "Docker Activity",
|
||||
description: "Recent Docker connection activity across your hosts",
|
||||
category: "system",
|
||||
icon: <Container size={14} />,
|
||||
defaultConfig: { maxItems: 10, showHostName: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
|
||||
component: DockerActivityWidget,
|
||||
});
|
||||
|
||||
export { DockerActivityWidget };
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
DockerWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { DockerManager } from "@/features/docker/DockerManager";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function DockerWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<DockerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const found = hosts.find(
|
||||
(h) => h.id === config.hostId && h.enableDocker,
|
||||
);
|
||||
setHost(found ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [config.hostId]);
|
||||
|
||||
if (!config.hostId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<Box size={20} />
|
||||
<span className="text-xs">{t("homepage.widgetNoHostSelected")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.widgetNoHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Box size={11} />} />
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DockerManager
|
||||
hostConfig={host as unknown as SSHHost}
|
||||
title={host.name || host.ip}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<DockerWidgetConfig>({
|
||||
id: "docker_widget",
|
||||
name: "Docker Manager",
|
||||
description: "Embedded Docker container manager for a configured host",
|
||||
category: "system",
|
||||
icon: <Box size={14} />,
|
||||
defaultConfig: { hostId: 0 },
|
||||
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
|
||||
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
component: DockerWidget,
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { FolderSearch } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
FileManagerWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { FileManager } from "@/features/file-manager/FileManager";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function FileManagerWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<FileManagerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const found = hosts.find((h) => h.id === config.hostId);
|
||||
setHost(found ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [config.hostId]);
|
||||
|
||||
if (!config.hostId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<FolderSearch size={20} />
|
||||
<span className="text-xs">{t("homepage.widgetNoHostSelected")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.widgetNoHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<FolderSearch size={11} />} />
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FileManager initialHost={host as unknown as SSHHost} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<FileManagerWidgetConfig>({
|
||||
id: "file_manager_widget",
|
||||
name: "File Manager",
|
||||
description: "Embedded SFTP file manager for a configured host",
|
||||
category: "system",
|
||||
icon: <FolderSearch size={14} />,
|
||||
defaultConfig: { hostId: 0 },
|
||||
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
|
||||
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
component: FileManagerWidget,
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Folder } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
FolderConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
function FolderWidget({ widget, config }: WidgetComponentProps<FolderConfig>) {
|
||||
const accentColor =
|
||||
config.color ||
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() ||
|
||||
"#f59145";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex flex-col"
|
||||
style={{
|
||||
background: `${accentColor}10`,
|
||||
borderTop: `2px solid ${accentColor}60`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 select-none">
|
||||
<Folder size={11} style={{ color: accentColor }} className="shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground truncate">
|
||||
{widget.title || "Folder"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<FolderConfig>({
|
||||
id: "folder",
|
||||
name: "Folder",
|
||||
description: "A background card for grouping widgets visually",
|
||||
category: "links",
|
||||
icon: <Folder size={14} />,
|
||||
defaultConfig: { isExpanded: true },
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: FolderWidget as unknown as React.ComponentType<
|
||||
WidgetComponentProps<FolderConfig>
|
||||
>,
|
||||
});
|
||||
|
||||
export { FolderWidget };
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
HostGridConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { getAllServerStatuses } from "@/api/host-metrics-status-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const color =
|
||||
status === "online"
|
||||
? getAccentColor()
|
||||
: status === "offline"
|
||||
? "#ef4444"
|
||||
: "#6b7280";
|
||||
return (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0 inline-block"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostGridWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<HostGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hostIds, showIp, columns } = config;
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [allHosts, statuses] = await Promise.all([
|
||||
getSSHHosts(),
|
||||
getAllServerStatuses().catch(
|
||||
() => ({}) as Record<number, { status: string }>,
|
||||
),
|
||||
]);
|
||||
const filtered =
|
||||
hostIds.length > 0
|
||||
? allHosts.filter((h) => hostIds.includes(h.id))
|
||||
: allHosts;
|
||||
const withStatus = filtered.map((h) => ({
|
||||
...h,
|
||||
status:
|
||||
(statuses as Record<number, { status: string }>)[h.id]?.status ??
|
||||
h.status ??
|
||||
"unknown",
|
||||
}));
|
||||
setHosts(withStatus);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, 10_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [hostIds.join(",")]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHosts")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols =
|
||||
columns === 2
|
||||
? "grid-cols-2"
|
||||
: columns === 3
|
||||
? "grid-cols-3"
|
||||
: "grid-cols-4";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<LayoutGrid size={11} />} />
|
||||
<div
|
||||
className={`grid ${gridCols} gap-1.5 p-2 flex-1 content-start overflow-auto`}
|
||||
>
|
||||
{hosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center gap-1.5 p-1.5 bg-muted/30 border border-border/40 overflow-hidden"
|
||||
>
|
||||
<StatusDot status={host.status ?? "unknown"} />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{host.name}
|
||||
</span>
|
||||
{showIp && (
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<HostGridConfig>({
|
||||
id: "host_grid",
|
||||
name: "Host Grid",
|
||||
description: "Live status overview grid for all your SSH hosts",
|
||||
category: "monitoring",
|
||||
icon: <LayoutGrid size={14} />,
|
||||
defaultConfig: { hostIds: [], showIp: false, columns: 3 },
|
||||
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: HostGridWidget,
|
||||
});
|
||||
|
||||
export { HostGridWidget };
|
||||
@@ -0,0 +1,336 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Server } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
HostStatusConfig,
|
||||
HostMetricKey,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
} from "@/api/host-metrics-status-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_METRICS: HostMetricKey[] = ["cpu", "memory"];
|
||||
|
||||
function migrateConfig(config: HostStatusConfig): HostMetricKey[] {
|
||||
if (config.shownMetrics?.length) return config.shownMetrics;
|
||||
if (config.showMetrics === false) return [];
|
||||
const metrics: HostMetricKey[] = ["cpu", "memory"];
|
||||
if (config.showDisk) metrics.push("disk");
|
||||
return metrics;
|
||||
}
|
||||
|
||||
interface MetricBarProps {
|
||||
label: string;
|
||||
value: number | null;
|
||||
sublabel?: string;
|
||||
}
|
||||
|
||||
function MetricBar({ label, value, sublabel }: MetricBarProps) {
|
||||
const pct = value ?? 0;
|
||||
const color = pct > 90 ? "#ef4444" : pct > 70 ? "#f97316" : getAccentColor();
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span className="text-right">
|
||||
{value != null ? `${Math.round(pct)}%` : "N/A"}
|
||||
{sublabel && <span className="ml-1 opacity-60">{sublabel}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{ width: `${pct}%`, background: color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InfoRowProps {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: InfoRowProps) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex justify-between text-[10px] gap-2">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className="text-foreground truncate text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n >= 1073741824) return `${(n / 1073741824).toFixed(1)} GB`;
|
||||
if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`;
|
||||
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
function HostStatusWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<HostStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const shownMetrics = migrateConfig(config);
|
||||
const { hostId } = config;
|
||||
const [hostName, setHostName] = useState<string | null>(null);
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const [metrics, setMetrics] = useState<ServerMetrics | null>(null);
|
||||
|
||||
const needsMetrics = shownMetrics.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return;
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const host = hosts.find((h) => h.id === hostId);
|
||||
if (host) setHostName(host.name);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [hostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return;
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await getServerStatusById(hostId);
|
||||
if (!cancelled) setOnline(s.status === "online");
|
||||
} catch {
|
||||
if (!cancelled) setOnline(false);
|
||||
}
|
||||
|
||||
if (needsMetrics) {
|
||||
try {
|
||||
const m = await getServerMetricsById(hostId);
|
||||
if (!cancelled) setMetrics(m);
|
||||
} catch {
|
||||
if (!cancelled) setMetrics(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
const iv = setInterval(poll, 10000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, [hostId, needsMetrics]);
|
||||
|
||||
if (!hostId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onlineColor =
|
||||
online === null ? "#6b7280" : online ? getAccentColor() : "#ef4444";
|
||||
const onlineLabel =
|
||||
online === null
|
||||
? t("common.unknown")
|
||||
: online
|
||||
? t("common.online")
|
||||
: t("common.offline");
|
||||
|
||||
const networkIfaces = metrics?.network?.interfaces ?? [];
|
||||
const totalRx = networkIfaces.reduce((sum, iface) => {
|
||||
const raw = iface.rxBytes ?? iface.rx ?? null;
|
||||
return raw ? sum + parseFloat(raw) : sum;
|
||||
}, 0);
|
||||
const totalTx = networkIfaces.reduce((sum, iface) => {
|
||||
const raw = iface.txBytes ?? iface.tx ?? null;
|
||||
return raw ? sum + parseFloat(raw) : sum;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Server size={11} />} />
|
||||
<div
|
||||
className={`flex flex-col gap-2.5 p-3 flex-1 overflow-auto ${!needsMetrics ? "justify-center" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Server size={14} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground truncate flex-1">
|
||||
{hostName ?? `Host #${hostId}`}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<span
|
||||
className="w-1.5 h-1.5 shrink-0 rounded-full"
|
||||
style={{ background: onlineColor }}
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: onlineColor }}
|
||||
>
|
||||
{onlineLabel}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{needsMetrics && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{shownMetrics.includes("cpu") && (
|
||||
<MetricBar
|
||||
label={t("homepage.metricCpu")}
|
||||
value={metrics?.cpu?.percent ?? null}
|
||||
sublabel={
|
||||
metrics?.cpu?.cores ? `${metrics.cpu.cores}c` : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("memory") && (
|
||||
<MetricBar
|
||||
label={t("homepage.metricMemory")}
|
||||
value={metrics?.memory?.percent ?? null}
|
||||
sublabel={
|
||||
metrics?.memory?.usedGiB != null &&
|
||||
metrics?.memory?.totalGiB != null
|
||||
? `${metrics.memory.usedGiB.toFixed(1)}/${metrics.memory.totalGiB.toFixed(1)} GiB`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("disk") && (
|
||||
<MetricBar
|
||||
label={t("homepage.metricDisk")}
|
||||
value={metrics?.disk?.percent ?? null}
|
||||
sublabel={
|
||||
metrics?.disk?.usedHuman && metrics?.disk?.totalHuman
|
||||
? `${metrics.disk.usedHuman}/${metrics.disk.totalHuman}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("uptime") && (
|
||||
<InfoRow
|
||||
label={t("homepage.metricUptime")}
|
||||
value={metrics?.uptime?.formatted ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("system") && metrics?.system && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
|
||||
<InfoRow
|
||||
label={t("homepage.metricOs")}
|
||||
value={metrics.system.os ?? null}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("homepage.metricKernel")}
|
||||
value={metrics.system.kernel ?? null}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("homepage.metricHostname")}
|
||||
value={metrics.system.hostname ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("network") && networkIfaces.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
|
||||
<div className="text-[10px] font-medium text-muted-foreground mb-0.5">
|
||||
{t("homepage.metricNetwork")}
|
||||
</div>
|
||||
{networkIfaces.slice(0, 3).map((iface) => (
|
||||
<div
|
||||
key={iface.name}
|
||||
className="flex justify-between text-[10px] gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 truncate max-w-[60px]">
|
||||
{iface.name}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{iface.ip || iface.state}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(totalRx > 0 || totalTx > 0) && (
|
||||
<div className="flex justify-between text-[10px] gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground">RX/TX</span>
|
||||
<span className="text-foreground">
|
||||
{formatBytes(totalRx)} / {formatBytes(totalTx)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("processes") && metrics?.processes && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">
|
||||
{t("homepage.metricProcesses")}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{metrics.processes.total ?? "?"}{" "}
|
||||
{t("homepage.metricProcessesTotal")}
|
||||
{metrics.processes.running != null
|
||||
? `, ${metrics.processes.running} ${t("homepage.metricProcessesRunning")}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
{metrics.processes.top?.slice(0, 3).map((proc) => (
|
||||
<div
|
||||
key={proc.pid}
|
||||
className="flex justify-between text-[10px] gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground truncate max-w-[80px]">
|
||||
{proc.command}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0">
|
||||
{proc.cpu}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsMetrics && !metrics && online && (
|
||||
<span className="text-[10px] text-muted-foreground/60 italic">
|
||||
{t("homepage.metricsNotAvailable")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<HostStatusConfig>({
|
||||
id: "host_status",
|
||||
name: "Host Status",
|
||||
description: "Shows live status and metrics for an SSH host",
|
||||
category: "system",
|
||||
icon: <Server size={14} />,
|
||||
defaultConfig: { hostId: 0, shownMetrics: DEFAULT_METRICS },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: HostStatusWidget,
|
||||
});
|
||||
|
||||
export { HostStatusWidget };
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Globe } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
IframeConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function IframeWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<IframeConfig>) {
|
||||
const { url, scrolling } = config;
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/50">
|
||||
<Globe size={24} />
|
||||
<span className="text-xs">Configure a URL in widget settings</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Globe size={11} />} />
|
||||
<div className="relative flex-1">
|
||||
<iframe
|
||||
src={url}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
scrolling={scrolling ? "yes" : "no"}
|
||||
className="w-full h-full border-0"
|
||||
title="Embedded content"
|
||||
/>
|
||||
{/* Block interaction in read-only mode so the canvas can still pan */}
|
||||
{isReadOnly && <div className="absolute inset-0 pointer-events-none" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<IframeConfig>({
|
||||
id: "iframe_embed",
|
||||
name: "iFrame Embed",
|
||||
description: "Embed any URL in an iframe",
|
||||
category: "info",
|
||||
icon: <Globe size={14} />,
|
||||
defaultConfig: { url: "", scrolling: true },
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: IframeWidget,
|
||||
});
|
||||
|
||||
export { IframeWidget };
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
ImageWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
const FIT_MAP = {
|
||||
contain: "object-contain",
|
||||
cover: "object-cover",
|
||||
fill: "object-fill",
|
||||
};
|
||||
|
||||
function ImageWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<ImageWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { imageUrl, fit, alt, linkUrl } = config;
|
||||
const [failed, setFailed] = useState(false);
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const titleBar = widget.title ? (
|
||||
<WidgetTitle title={widget.title} icon={<ImageIcon size={11} />} />
|
||||
) : null;
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 flex items-center justify-center overflow-hidden">
|
||||
{imageUrl && !failed ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={alt ?? ""}
|
||||
className={`w-full h-full ${FIT_MAP[fit]}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-muted-foreground/40">
|
||||
<ImageIcon size={24} />
|
||||
<span className="text-[10px]">{t("homepage.noImage")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !linkUrl) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ImageWidgetConfig>({
|
||||
id: "image_widget",
|
||||
name: "Image",
|
||||
description: "Display any image from a URL",
|
||||
category: "info",
|
||||
icon: <ImageIcon size={14} />,
|
||||
defaultConfig: { imageUrl: "", fit: "contain" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: ImageWidget,
|
||||
});
|
||||
|
||||
export { ImageWidget };
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useRef } from "react";
|
||||
import { List, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
LinkTreeConfig,
|
||||
LinkTreeLink,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function TreeLink({
|
||||
link,
|
||||
compact,
|
||||
isReadOnly,
|
||||
}: {
|
||||
link: LinkTreeLink;
|
||||
compact: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`group flex items-center gap-1.5 ${compact ? "py-0.5" : "py-1"} pl-2 pr-1 hover:bg-muted/40 transition-colors overflow-hidden`}
|
||||
>
|
||||
<ExternalLink
|
||||
size={9}
|
||||
className="shrink-0 text-muted-foreground group-hover:text-accent-brand transition-colors"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{link.label}
|
||||
</span>
|
||||
{!compact && link.description && (
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{link.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !link.url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkTreeWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<LinkTreeConfig>) {
|
||||
const { sections, compact } = config;
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
No sections configured
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<List size={11} />} />
|
||||
<div className="flex-1 overflow-auto">
|
||||
{sections.map((section, si) => (
|
||||
<div key={si}>
|
||||
{section.heading && (
|
||||
<div className="flex items-center gap-1.5 px-2 pt-2 pb-0.5 border-b border-border/30">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
{section.heading}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{section.links.map((link, li) => (
|
||||
<TreeLink
|
||||
key={li}
|
||||
link={link}
|
||||
compact={compact}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<LinkTreeConfig>({
|
||||
id: "link_tree",
|
||||
name: "Link Tree",
|
||||
description: "Grouped links organized into labeled sections",
|
||||
category: "links",
|
||||
icon: <List size={14} />,
|
||||
defaultConfig: { sections: [], compact: false },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
|
||||
component: LinkTreeWidget,
|
||||
});
|
||||
|
||||
export { LinkTreeWidget };
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
MarkdownNotesConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { updateHomepageItem } from "@/api/homepage-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function renderMarkdown(src: string): string {
|
||||
return src
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (/^### /.test(line))
|
||||
return `<h3 class="text-[11px] font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(4))}</h3>`;
|
||||
if (/^## /.test(line))
|
||||
return `<h2 class="text-xs font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(3))}</h2>`;
|
||||
if (/^# /.test(line))
|
||||
return `<h1 class="text-sm font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(2))}</h1>`;
|
||||
if (/^- /.test(line))
|
||||
return `<li class="text-[10px] ml-3 list-disc text-foreground">${inlineFormat(line.slice(2))}</li>`;
|
||||
if (/^\d+\. /.test(line))
|
||||
return `<li class="text-[10px] ml-3 list-decimal text-foreground">${inlineFormat(line.replace(/^\d+\. /, ""))}</li>`;
|
||||
if (line.trim() === "") return "<br/>";
|
||||
return `<p class="text-[10px] text-foreground leading-relaxed">${inlineFormat(line)}</p>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function inlineFormat(s: string): string {
|
||||
return esc(s)
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
||||
.replace(
|
||||
/`(.+?)`/g,
|
||||
'<code class="bg-muted px-0.5 font-mono text-[9px]">$1</code>',
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownNotesWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
onConfigUpdate,
|
||||
}: WidgetComponentProps<MarkdownNotesConfig>) {
|
||||
const { content, backgroundColor, renderMarkdown: doRender } = config;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(content);
|
||||
|
||||
const commitEdit = useCallback(async () => {
|
||||
setEditing(false);
|
||||
if (draft === content) return;
|
||||
try {
|
||||
await updateHomepageItem(widget.id, {
|
||||
config: { ...config, content: draft } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
onConfigUpdate?.({ ...config, content: draft } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [draft, content, widget.id, config, onConfigUpdate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full h-full overflow-hidden"
|
||||
style={backgroundColor ? { background: backgroundColor } : undefined}
|
||||
>
|
||||
<WidgetTitle title={widget.title} icon={<FileText size={11} />} />
|
||||
<div className="flex-1 overflow-auto p-2 relative">
|
||||
{editing ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="w-full h-full bg-transparent text-[10px] text-foreground resize-none outline-none border-none leading-relaxed"
|
||||
style={{ minHeight: "100%" }}
|
||||
/>
|
||||
) : doRender ? (
|
||||
<div
|
||||
className="text-[10px] leading-relaxed cursor-text"
|
||||
onDoubleClick={() => {
|
||||
if (!isReadOnly) setEditing(true);
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content || "") }}
|
||||
/>
|
||||
) : (
|
||||
<pre
|
||||
className="text-[10px] text-foreground leading-relaxed whitespace-pre-wrap break-words font-sans cursor-text"
|
||||
onDoubleClick={() => {
|
||||
if (!isReadOnly) setEditing(true);
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<MarkdownNotesConfig>({
|
||||
id: "markdown_notes",
|
||||
name: "Markdown Notes",
|
||||
description: "Notes with optional Markdown rendering and inline editing",
|
||||
category: "info",
|
||||
icon: <FileText size={14} />,
|
||||
defaultConfig: { content: "", renderMarkdown: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 3 },
|
||||
component: MarkdownNotesWidget,
|
||||
});
|
||||
|
||||
export { MarkdownNotesWidget };
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
MetricsChartConfig,
|
||||
MetricsChartMetric,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getMetricsHistory } from "@/api/host-metrics-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { MetricsHistoryRow } from "@/api/host-metrics-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function getMetricValue(
|
||||
row: MetricsHistoryRow,
|
||||
metric: MetricsChartMetric,
|
||||
): number | null {
|
||||
switch (metric) {
|
||||
case "cpu":
|
||||
return row.cpu_percent;
|
||||
case "memory":
|
||||
return row.mem_percent;
|
||||
case "disk":
|
||||
return row.disk_percent;
|
||||
case "net_rx":
|
||||
return row.net_rx_bytes != null ? row.net_rx_bytes / 1_000_000 : null;
|
||||
case "net_tx":
|
||||
return row.net_tx_bytes != null ? row.net_tx_bytes / 1_000_000 : null;
|
||||
}
|
||||
}
|
||||
|
||||
function getMetricLabel(
|
||||
metric: MetricsChartMetric,
|
||||
t: (k: string) => string,
|
||||
): string {
|
||||
switch (metric) {
|
||||
case "cpu":
|
||||
return t("homepage.metricCpu");
|
||||
case "memory":
|
||||
return t("homepage.metricMemory");
|
||||
case "disk":
|
||||
return t("homepage.metricDisk");
|
||||
case "net_rx":
|
||||
return "RX MB/s";
|
||||
case "net_tx":
|
||||
return "TX MB/s";
|
||||
}
|
||||
}
|
||||
|
||||
function getMetricUnit(metric: MetricsChartMetric): string {
|
||||
if (metric === "net_rx" || metric === "net_tx") return " MB/s";
|
||||
return "%";
|
||||
}
|
||||
|
||||
interface SparklineProps {
|
||||
values: number[];
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function Sparkline({ values, width, height, color }: SparklineProps) {
|
||||
if (values.length < 2) return null;
|
||||
const max = Math.max(...values, 1);
|
||||
const min = 0;
|
||||
const range = max - min;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * width;
|
||||
const y = height - ((v - min) / range) * height;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
});
|
||||
const polyPts = [`0,${height}`, ...pts, `${width},${height}`].join(" ");
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="overflow-visible"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`mg-${color.replace("#", "")}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points={polyPts} fill={`url(#mg-${color.replace("#", "")})`} />
|
||||
<polyline
|
||||
points={pts.join(" ")}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsChartWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<MetricsChartConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hostId, metric, range, showCurrentValue } = config;
|
||||
const [hostName, setHostName] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dims, setDims] = useState({ w: 0, h: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return;
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const h = hosts.find((x) => x.id === hostId);
|
||||
if (h) setHostName(h.name);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [hostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const obs = new ResizeObserver((entries) => {
|
||||
const e = entries[0]?.contentRect;
|
||||
if (e) setDims({ w: Math.floor(e.width), h: Math.floor(e.height) });
|
||||
});
|
||||
obs.observe(containerRef.current);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
const fetchData = () => {
|
||||
if (!hostId) return;
|
||||
getMetricsHistory(hostId, { range })
|
||||
.then((res) => {
|
||||
const vals = res.rows
|
||||
.map((r) => getMetricValue(r, metric))
|
||||
.filter((v): v is number => v != null);
|
||||
setValues(vals);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, 60_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [hostId, metric, range]);
|
||||
|
||||
const accent = getAccentColor();
|
||||
const current = values.length > 0 ? values[values.length - 1] : null;
|
||||
const unit = getMetricUnit(metric);
|
||||
const label = getMetricLabel(metric, t);
|
||||
|
||||
if (!hostId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<TrendingUp size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-1 shrink-0">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-semibold text-foreground truncate">
|
||||
{hostName ?? `Host #${hostId}`}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{label} · {range}
|
||||
</span>
|
||||
</div>
|
||||
{showCurrentValue && current != null && (
|
||||
<span
|
||||
className="text-sm font-bold tabular-nums shrink-0"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{metric === "net_rx" || metric === "net_tx"
|
||||
? current.toFixed(2)
|
||||
: Math.round(current)}
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="flex-1 min-h-0 relative">
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
) : values.length < 2 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.noData")}
|
||||
</div>
|
||||
) : (
|
||||
dims.w > 0 &&
|
||||
dims.h > 0 && (
|
||||
<Sparkline
|
||||
values={values}
|
||||
width={dims.w}
|
||||
height={dims.h}
|
||||
color={accent}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<MetricsChartConfig>({
|
||||
id: "metrics_chart",
|
||||
name: "Metrics Chart",
|
||||
description: "Historical CPU, memory, disk, or network chart for a host",
|
||||
category: "monitoring",
|
||||
icon: <TrendingUp size={14} />,
|
||||
defaultConfig: {
|
||||
hostId: 0,
|
||||
metric: "cpu",
|
||||
range: "1h",
|
||||
showCurrentValue: true,
|
||||
},
|
||||
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 4 },
|
||||
component: MetricsChartWidget,
|
||||
});
|
||||
|
||||
export { MetricsChartWidget };
|
||||
@@ -0,0 +1,39 @@
|
||||
import { StickyNote } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { NotesConfig, WidgetComponentProps } from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function NotesWidget({ widget, config }: WidgetComponentProps<NotesConfig>) {
|
||||
const { content, backgroundColor } = config;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full h-full overflow-hidden"
|
||||
style={{ background: backgroundColor || undefined }}
|
||||
>
|
||||
<WidgetTitle title={widget.title} icon={<StickyNote size={11} />} />
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
<pre className="text-xs text-foreground whitespace-pre-wrap break-words font-sans leading-relaxed select-text w-full">
|
||||
{content || (
|
||||
<span className="text-muted-foreground italic">Empty note...</span>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<NotesConfig>({
|
||||
id: "notes",
|
||||
name: "Notes",
|
||||
description: "A text notes widget",
|
||||
category: "info",
|
||||
icon: <StickyNote size={14} />,
|
||||
defaultConfig: { content: "" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: NotesWidget,
|
||||
});
|
||||
|
||||
export { NotesWidget };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user