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:
Luke Gustafson
2026-06-29 13:28:26 -05:00
committed by GitHub
co-authored by dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> sash LukeGus DivByZero devdanetra Aleksandr Fominykh ZacharyZcR
parent fd27a366d0
commit 9de904dd4c
348 changed files with 125184 additions and 76537 deletions
+198 -53
View File
@@ -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">;
+81 -66
View File
@@ -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/");
});
});
+59 -39
View File
@@ -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/`;
}
+12
View File
@@ -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;
+571
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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 };
@@ -0,0 +1,138 @@
import { useEffect, useState } from "react";
import { Wifi } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
PingStatusConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { homepageApi } from "@/main-axios";
import { WidgetTitle } from "./WidgetTitle";
interface PingResult {
ok: boolean;
statusCode: number | null;
latencyMs: number;
}
async function pingUrl(url: string): Promise<PingResult> {
const res = await homepageApi.get("/ping", { params: { url } });
return res.data as PingResult;
}
function StatusDot({ ok, pending }: { ok: boolean | null; pending: boolean }) {
if (pending)
return (
<span className="w-2 h-2 rounded-full bg-muted-foreground/40 shrink-0 inline-block" />
);
return (
<span
className="w-2 h-2 rounded-full shrink-0 inline-block"
style={{ background: ok ? "var(--accent-brand, #f59145)" : "#ef4444" }}
/>
);
}
function PingStatusWidget({
widget,
config,
}: WidgetComponentProps<PingStatusConfig>) {
const { t } = useTranslation();
const { urls, refreshInterval, showLatency } = config;
const [results, setResults] = useState<Record<string, PingResult | null>>({});
const [pending, setPending] = useState<Record<string, boolean>>({});
const fetchAll = async () => {
if (urls.length === 0) return;
const newPending: Record<string, boolean> = {};
urls.forEach((u) => {
newPending[u.url] = true;
});
setPending(newPending);
await Promise.all(
urls.map(async (u) => {
try {
const r = await pingUrl(u.url);
setResults((prev) => ({ ...prev, [u.url]: r }));
} catch {
setResults((prev) => ({
...prev,
[u.url]: { ok: false, statusCode: null, latencyMs: 0 },
}));
} finally {
setPending((prev) => ({ ...prev, [u.url]: false }));
}
}),
);
};
const interval = Math.max(10, refreshInterval || 30);
useEffect(() => {
fetchAll();
const iv = setInterval(fetchAll, interval * 1000);
return () => clearInterval(iv);
}, [JSON.stringify(urls), interval]);
if (urls.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noPingUrls")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle
title={widget.title || t("homepage.widgetPingStatusName")}
icon={<Wifi size={11} />}
/>
<div className="flex-1 overflow-auto">
{urls.map((u) => {
const r = results[u.url];
const isPending = pending[u.url] ?? false;
return (
<div
key={u.url}
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
>
<StatusDot ok={r?.ok ?? null} pending={isPending} />
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[10px] font-medium text-foreground truncate">
{u.label || u.url}
</span>
{r && (
<span className="text-[9px] text-muted-foreground">
{r.statusCode ?? (r.ok ? "OK" : "ERR")}
</span>
)}
</div>
{showLatency && r && r.latencyMs > 0 && (
<span className="text-[9px] text-muted-foreground shrink-0 tabular-nums">
{r.latencyMs}ms
</span>
)}
</div>
);
})}
</div>
</div>
);
}
registerWidget<PingStatusConfig>({
id: "ping_status",
name: "Ping Status",
description: "Check if URLs or services are reachable",
category: "monitoring",
icon: <Wifi size={14} />,
defaultConfig: { urls: [], refreshInterval: 30, showLatency: true },
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 7 },
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
component: PingStatusWidget,
});
export { PingStatusWidget };
@@ -0,0 +1,171 @@
import { useState, useEffect } from "react";
import {
Terminal,
FolderSearch,
Box,
Network,
Server,
Monitor,
Tv,
Phone,
Zap,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
QuickConnectConfig,
QuickConnectType,
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 type { TabType } from "@/types/ui-types";
import { WidgetTitle } from "./WidgetTitle";
const TYPE_ICONS: Record<QuickConnectType, React.ReactNode> = {
terminal: <Terminal size={12} />,
files: <FolderSearch size={12} />,
docker: <Box size={12} />,
tunnel: <Network size={12} />,
"host-metrics": <Server size={12} />,
rdp: <Monitor size={12} />,
vnc: <Tv size={12} />,
telnet: <Phone size={12} />,
};
type EnableCheck = (host: SSHHostWithStatus) => boolean;
const TYPE_ENABLED: Record<QuickConnectType, EnableCheck> = {
terminal: (h) => !!(h.enableSsh && h.enableTerminal),
files: (h) => !!(h.enableSsh && h.enableFileManager),
docker: (h) => !!(h.enableSsh && h.enableDocker),
tunnel: (h) => !!(h.enableSsh && h.enableTunnel),
"host-metrics": (h) => !!h.enableSsh,
rdp: (h) => !!h.enableRdp,
vnc: (h) => !!h.enableVnc,
telnet: (h) => !!h.enableTelnet,
};
function statusDotClass(host: SSHHostWithStatus): string {
if (host.status === "online") return "bg-green-500";
if (host.status === "offline") return "bg-red-500";
return "bg-muted-foreground/30";
}
function openTab(host: SSHHostWithStatus, type: QuickConnectType) {
window.dispatchEvent(
new CustomEvent("termix:open-tab", {
detail: { hostId: String(host.id), type: type as TabType },
}),
);
}
function QuickConnectWidget({
widget,
config,
}: WidgetComponentProps<QuickConnectConfig>) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
const [loading, setLoading] = useState(true);
const types =
config.connectionTypes.length > 0
? config.connectionTypes
: (["terminal"] as QuickConnectType[]);
useEffect(() => {
getSSHHosts()
.then((all) => {
const filtered =
config.hostIds.length > 0
? all.filter((h) => config.hostIds.includes(h.id))
: all;
setHosts(filtered);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [config.hostIds]);
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 (hosts.length === 0) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
<Zap size={18} />
<span className="text-xs">{t("homepage.noHosts")}</span>
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Zap size={11} />} />
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1">
{hosts.map((host) => {
const availableTypes = types.filter((type) =>
TYPE_ENABLED[type](host),
);
if (availableTypes.length === 0) return null;
return (
<div
key={host.id}
className={`flex items-center gap-2 py-1.5 border-b border-border/20 last:border-0 ${config.layout === "grid" ? "flex-col items-start" : ""}`}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
{config.showStatus && (
<span
className={`size-1.5 rounded-full shrink-0 ${statusDotClass(host)}`}
/>
)}
<span className="text-xs font-medium text-foreground truncate">
{host.name || host.ip}
</span>
</div>
<div className="flex items-center gap-1 shrink-0 flex-wrap">
{availableTypes.map((type) => (
<button
key={type}
title={t(`homepage.connType_${type}`, type)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
openTab(host, type);
}}
className="p-1.5 text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors border border-transparent hover:border-accent-brand/20"
>
{TYPE_ICONS[type]}
</button>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
registerWidget<QuickConnectConfig>({
id: "quick_connect",
name: "Quick Connect",
description:
"Launch terminal, files, docker, tunnels, and more for your hosts",
category: "system",
icon: <Zap size={14} />,
defaultConfig: {
hostIds: [],
connectionTypes: ["terminal", "files"],
showStatus: true,
layout: "list",
},
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: QuickConnectWidget,
});
@@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import {
Activity,
Terminal,
FolderOpen,
Container,
Wifi,
Monitor,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
RecentActivityConfig,
ActivityType,
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 ActivityIcon({ type }: { type: string }) {
const cls = "shrink-0 text-accent-brand";
switch (type) {
case "terminal":
return <Terminal size={10} className={cls} />;
case "file_manager":
return <FolderOpen size={10} className={cls} />;
case "docker":
return <Container size={10} className={cls} />;
case "tunnel":
return <Wifi size={10} className={cls} />;
case "rdp":
case "vnc":
case "telnet":
return <Monitor size={10} className={cls} />;
default:
return <Activity size={10} className={cls} />;
}
}
function RecentActivityWidget({
widget,
config,
}: WidgetComponentProps<RecentActivityConfig>) {
const { t } = useTranslation();
const { maxItems, filterTypes, showTimestamp } = config;
const [items, setItems] = useState<RecentActivityItem[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = async () => {
try {
const data = await getRecentActivity(maxItems * 3);
const filtered =
filterTypes.length > 0
? data.filter((i) => filterTypes.includes(i.type as ActivityType))
: data;
setItems(filtered.slice(0, maxItems));
} catch {
/* ignore */
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
const iv = setInterval(fetchData, 60_000);
return () => clearInterval(iv);
}, [maxItems, filterTypes.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 (items.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noActivity")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle
title={widget.title || t("homepage.widgetRecentActivityName")}
icon={<Activity 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"
>
<ActivityIcon type={item.type} />
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[10px] font-medium text-foreground truncate">
{item.hostName}
</span>
<span className="text-[9px] text-muted-foreground capitalize">
{item.type.replace("_", " ")}
</span>
</div>
{showTimestamp && (
<span className="text-[9px] text-muted-foreground shrink-0">
{relativeTime(item.timestamp)}
</span>
)}
</div>
))}
</div>
</div>
);
}
registerWidget<RecentActivityConfig>({
id: "recent_activity",
name: "Recent Activity",
description: "Shows recent terminal, Docker, file, and tunnel activity",
category: "system",
icon: <Activity size={14} />,
defaultConfig: { maxItems: 10, filterTypes: [], showTimestamp: true },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 9 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: RecentActivityWidget,
});
export { RecentActivityWidget };
@@ -0,0 +1,155 @@
import { useEffect, useState } from "react";
import { Rss, ExternalLink } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import { homepageApi } from "@/main-axios";
import type {
RssFeedConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { WidgetTitle } from "./WidgetTitle";
interface RssItem {
title: string;
link: string;
pubDate: string | null;
description: string | null;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return "";
try {
const d = new Date(dateStr);
const diffMs = Date.now() - d.getTime();
const diffH = Math.floor(diffMs / 3_600_000);
if (diffH < 1) return "just now";
if (diffH < 24) return `${diffH}h ago`;
const diffD = Math.floor(diffH / 24);
if (diffD < 7) return `${diffD}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} catch {
return "";
}
}
function RssFeedWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<RssFeedConfig>) {
const { feedUrl, maxItems, showDescription } = config;
const [items, setItems] = useState<RssItem[]>([]);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!feedUrl) return;
let cancelled = false;
const fetchFeed = async () => {
try {
setLoading(true);
setError(false);
const res = await homepageApi.get(
`/rss?url=${encodeURIComponent(feedUrl)}&max=${maxItems ?? 10}`,
);
if (!cancelled) setItems(res.data);
} catch {
if (!cancelled) setError(true);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchFeed();
return () => {
cancelled = true;
};
}, [feedUrl, maxItems]);
if (!feedUrl) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/50">
<Rss size={24} />
<span className="text-xs">Configure a feed URL in widget settings</span>
</div>
);
}
if (loading && items.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/50">
Loading...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-destructive/70 p-3 text-center">
Could not load feed
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle
title={widget.title || "RSS Feed"}
icon={<Rss size={11} />}
/>
{/* Items */}
<div className="flex-1 overflow-y-auto">
{items.length === 0 ? (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground/50">
No items
</div>
) : (
items.map((item, i) => (
<div key={i} className="border-b border-border/60 last:border-0">
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className={`flex flex-col px-3 py-2 gap-0.5 hover:bg-muted/40 transition-colors no-underline ${isReadOnly ? "pointer-events-none" : ""}`}
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-medium text-foreground line-clamp-2 leading-snug flex-1">
{item.title}
</span>
<ExternalLink
size={9}
className="text-muted-foreground/40 shrink-0 mt-0.5"
/>
</div>
{showDescription && item.description && (
<span className="text-[10px] text-muted-foreground/70 line-clamp-2">
{item.description.replace(/<[^>]+>/g, "")}
</span>
)}
{item.pubDate && (
<span className="text-[9px] text-muted-foreground/50">
{formatDate(item.pubDate)}
</span>
)}
</a>
</div>
))
)}
</div>
</div>
);
}
registerWidget<RssFeedConfig>({
id: "rss_feed",
name: "RSS Feed",
description: "Display items from an RSS or Atom feed",
category: "links",
icon: <Rss size={14} />,
defaultConfig: { feedUrl: "", maxItems: 10, showDescription: false },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 10 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: RssFeedWidget,
});
export { RssFeedWidget };
@@ -0,0 +1,103 @@
import { useRef, useState } from "react";
import { Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
SearchBarConfig,
SearchEngine,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { WidgetTitle } from "./WidgetTitle";
const ENGINE_URLS: Record<SearchEngine, string> = {
google: "https://www.google.com/search?q={q}",
duckduckgo: "https://duckduckgo.com/?q={q}",
bing: "https://www.bing.com/search?q={q}",
custom: "",
};
const ENGINE_LABELS: Record<SearchEngine, string> = {
google: "Google",
duckduckgo: "DuckDuckGo",
bing: "Bing",
custom: "Custom",
};
function buildUrl(
engine: SearchEngine,
customUrl: string | undefined,
query: string,
): string {
const template =
engine === "custom" ? (customUrl ?? "") : ENGINE_URLS[engine];
return template.replace("{q}", encodeURIComponent(query));
}
function SearchBarWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<SearchBarConfig>) {
const { t } = useTranslation();
const { engine, customUrl, placeholder, openInNewTab } = config;
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
const url = buildUrl(engine, customUrl, query.trim());
if (!url) return;
if (openInNewTab) window.open(url, "_blank", "noopener,noreferrer");
else window.location.href = url;
setQuery("");
};
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Search size={11} />} />
<form
onSubmit={handleSubmit}
className="flex items-center gap-1.5 flex-1 px-2"
onMouseDown={(e) => e.stopPropagation()}
>
<Search size={12} className="text-muted-foreground shrink-0" />
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={
placeholder ||
`${t("homepage.searchPlaceholder")} ${ENGINE_LABELS[engine]}...`
}
disabled={isReadOnly}
className="flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground/60 outline-none border-none min-w-0"
/>
{query.trim() && (
<button
type="submit"
className="shrink-0 text-[10px] font-medium text-accent-brand hover:opacity-80 transition-opacity"
>
{t("homepage.searchGo")}
</button>
)}
</form>
</div>
);
}
registerWidget<SearchBarConfig>({
id: "search_bar",
name: "Search Bar",
description: "Quick search with Google, DuckDuckGo, Bing, or a custom engine",
category: "info",
icon: <Search size={14} />,
defaultConfig: { engine: "google", openInNewTab: true },
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 3 },
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 2 },
component: SearchBarWidget,
});
export { SearchBarWidget };
@@ -0,0 +1,129 @@
import { useState } from "react";
import { SearchCode } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
SearchLinksConfig,
SearchLinkShortcut,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { WidgetTitle } from "./WidgetTitle";
function SearchLinksWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<SearchLinksConfig>) {
const { t } = useTranslation();
const { shortcuts } = config;
const [activeIdx, setActiveIdx] = useState<number | null>(null);
const [query, setQuery] = useState("");
const handleOpen = (shortcut: SearchLinkShortcut) => {
if (!query.trim()) return;
const url = shortcut.queryTemplate.replace(
"{q}",
encodeURIComponent(query.trim()),
);
window.open(url, "_blank", "noopener,noreferrer");
setQuery("");
setActiveIdx(null);
};
const handleButtonClick = (i: number) => {
if (isReadOnly) return;
if (activeIdx === i) {
setActiveIdx(null);
setQuery("");
} else {
setActiveIdx(i);
setQuery("");
}
};
if (shortcuts.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noSearchShortcuts")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<SearchCode size={11} />} />
<div className="flex flex-col flex-1 p-2 gap-1.5 overflow-auto">
<div className="flex flex-wrap gap-1.5">
{shortcuts.map((s, i) => {
const accent =
s.accentColor ||
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() ||
"#f59145";
const isActive = activeIdx === i;
return (
<button
key={i}
onClick={() => handleButtonClick(i)}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium border transition-colors"
style={
isActive
? { background: accent, borderColor: accent, color: "#fff" }
: {
borderColor: `${accent}50`,
color: "var(--text-primary, currentColor)",
}
}
>
{s.icon && <span className="text-xs">{s.icon}</span>}
{s.label}
</button>
);
})}
</div>
{activeIdx !== null && (
<form
onSubmit={(e) => {
e.preventDefault();
handleOpen(shortcuts[activeIdx]);
}}
className="flex items-center gap-1.5 border border-border/60 px-2 py-1"
onMouseDown={(e) => e.stopPropagation()}
>
<input
autoFocus
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("homepage.searchQueryPlaceholder")}
className="flex-1 bg-transparent text-[10px] text-foreground placeholder:text-muted-foreground/60 outline-none border-none"
/>
<button
type="submit"
className="text-[9px] font-medium text-accent-brand hover:opacity-80"
>
{t("homepage.searchGo")}
</button>
</form>
)}
</div>
</div>
);
}
registerWidget<SearchLinksConfig>({
id: "search_links",
name: "Search Shortcuts",
description: "Quick buttons to search different engines",
category: "links",
icon: <SearchCode size={14} />,
defaultConfig: { shortcuts: [] },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: SearchLinksWidget,
});
export { SearchLinksWidget };
@@ -0,0 +1,160 @@
import { useRef, useState, useEffect } from "react";
import { Grid3x3, ExternalLink } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type {
ServiceGridConfig,
ServiceGridItem,
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 getFaviconUrl(url: string): string {
try {
const domain = new URL(url).hostname;
return `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
} catch {
return "";
}
}
const ICON_SIZE_MAP = { sm: 16, md: 24, lg: 32 };
function ServiceTile({
item,
iconSize,
showLabels,
isReadOnly,
}: {
item: ServiceGridItem;
iconSize: number;
showLabels: boolean;
isReadOnly?: boolean;
}) {
const [imgFailed, setImgFailed] = useState(false);
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
setImgFailed(false);
}, [item.imageUrl, item.url]);
const accent = item.accentColor || getAccentColor();
const imageUrl = item.imageUrl || (item.url ? getFaviconUrl(item.url) : null);
const content = (
<div
className="group flex flex-col items-center justify-center gap-1 p-1.5 w-full h-full border border-border/40 border-t-2 hover:bg-muted/40 transition-colors overflow-hidden"
style={{ borderTopColor: `${accent}60`, background: "var(--color-card)" }}
>
{imageUrl && !imgFailed ? (
<img
src={imageUrl}
alt=""
style={{ width: iconSize, height: iconSize }}
className="object-contain shrink-0"
onError={() => setImgFailed(true)}
/>
) : (
<ExternalLink
size={iconSize * 0.6}
className="text-accent-brand shrink-0"
/>
)}
{showLabels && (
<span className="text-[9px] font-medium text-foreground text-center truncate w-full leading-tight">
{item.label || item.url}
</span>
)}
</div>
);
if (isReadOnly || !item.url) return content;
return (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full 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 ServiceGridWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<ServiceGridConfig>) {
const { services, columns, showLabels, iconSize } = config;
if (services.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
No services configured
</div>
);
}
const gridCols =
columns === 2
? "grid-cols-2"
: columns === 3
? "grid-cols-3"
: "grid-cols-4";
const size = ICON_SIZE_MAP[iconSize];
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Grid3x3 size={11} />} />
<div
className={`grid ${gridCols} gap-1.5 p-1.5 flex-1 overflow-auto content-start`}
>
{services.map((s, i) => (
<ServiceTile
key={i}
item={s}
iconSize={size}
showLabels={showLabels}
isReadOnly={isReadOnly}
/>
))}
</div>
</div>
);
}
registerWidget<ServiceGridConfig>({
id: "service_grid",
name: "Service Grid",
description: "A grid of service tiles with icons and links",
category: "links",
icon: <Grid3x3 size={14} />,
defaultConfig: { services: [], columns: 3, showLabels: true, iconSize: "md" },
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 10 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
component: ServiceGridWidget,
});
export { ServiceGridWidget };
@@ -0,0 +1,131 @@
import { useRef, useState, useEffect } from "react";
import { ExternalLink } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type {
ServiceLinkConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
function getAccentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
function getFaviconUrl(url: string): string {
try {
const domain = new URL(url).hostname;
return `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
} catch {
return "";
}
}
function ServiceLinkWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<ServiceLinkConfig>) {
const { url, description, accentColor, imageUrl, showImage = true } = config;
const resolvedImageUrl = showImage
? imageUrl || (url ? getFaviconUrl(url) : null)
: null;
const mouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
const [imgFailed, setImgFailed] = useState(false);
useEffect(() => {
setImgFailed(false);
}, [resolvedImageUrl]);
let displayLabel = widget.title;
if (!displayLabel && url) {
try {
displayLabel = new URL(url).hostname;
} catch {
displayLabel = url;
}
}
const accentBorder = accentColor || getAccentColor();
const imgSize = Math.min(48, widget.h * 0.3);
const content = (
<div
className="group flex flex-col items-center justify-center gap-1.5 w-full h-full p-2 select-none border-t-2 relative overflow-hidden"
style={{ borderTopColor: `${accentBorder}80` }}
>
{resolvedImageUrl && !imgFailed ? (
<img
src={resolvedImageUrl}
alt=""
className="shrink-0 transition-transform duration-150 group-hover:scale-110 object-contain"
style={{ width: imgSize, height: imgSize }}
onError={() => setImgFailed(true)}
/>
) : showImage ? (
<ExternalLink
className="shrink-0 text-accent-brand transition-transform duration-150 group-hover:scale-110"
style={{ width: imgSize * 0.75, height: imgSize * 0.75 }}
/>
) : null}
<span className="text-xs font-semibold text-foreground text-center leading-tight w-full truncate px-1">
{displayLabel}
</span>
{description && (
<span className="text-[10px] text-muted-foreground text-center w-full truncate px-1">
{description}
</span>
)}
{!isReadOnly && url && (
<ExternalLink
size={10}
className="absolute top-1.5 right-1.5 text-muted-foreground/0 group-hover:text-muted-foreground/60 transition-colors duration-150"
/>
)}
</div>
);
if (isReadOnly || !url) return content;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full no-underline"
onMouseDown={(e) => {
mouseDownPosRef.current = { x: e.clientX, y: e.clientY };
}}
onClick={(e) => {
const start = mouseDownPosRef.current;
if (
start &&
(Math.abs(e.clientX - start.x) > 4 ||
Math.abs(e.clientY - start.y) > 4)
) {
e.preventDefault();
}
mouseDownPosRef.current = null;
}}
>
{content}
</a>
);
}
registerWidget<ServiceLinkConfig>({
id: "service_link",
name: "Service Link",
description: "A clickable tile linking to a service URL",
category: "links",
icon: <ExternalLink size={14} />,
defaultConfig: { url: "", description: "", showImage: true },
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: ServiceLinkWidget,
});
export { ServiceLinkWidget };
@@ -0,0 +1,162 @@
import { useEffect, useState } from "react";
import { Zap, Terminal, FolderOpen, Container } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
SshQuickConnectConfig,
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 TypeIcon({ type }: { type: string }) {
if (type === "files") return <FolderOpen size={10} />;
if (type === "docker") return <Container size={10} />;
return <Terminal size={10} />;
}
function openTab(host: SSHHostWithStatus, connectionType: string) {
window.dispatchEvent(
new CustomEvent("termix:open-tab", {
detail: {
type:
connectionType === "files"
? "files"
: connectionType === "docker"
? "docker"
: "terminal",
host,
},
}),
);
}
function SshQuickConnectWidget({
widget,
config,
}: WidgetComponentProps<SshQuickConnectConfig>) {
const { t } = useTranslation();
const { hostIds, connectionType, showStatus, layout } = 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;
setHosts(
filtered.map((h) => ({
...h,
status:
(statuses as Record<number, { status: string }>)[h.id]?.status ??
h.status ??
"unknown",
})),
);
} 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 accent = getAccentColor();
const isGrid = layout === "grid";
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Zap size={11} />} />
<div
className={`${isGrid ? "grid grid-cols-2 content-start" : "flex flex-col"} gap-1.5 p-2 flex-1 overflow-auto`}
>
{hosts.map((host) => {
const online = host.status === "online";
const statusColor =
host.status === "online"
? accent
: host.status === "offline"
? "#ef4444"
: "#6b7280";
return (
<button
key={host.id}
onClick={() => openTab(host, connectionType)}
className="flex items-center gap-1.5 p-2 bg-muted/30 border border-border/40 hover:border-accent-brand/50 hover:bg-muted/60 transition-colors text-left overflow-hidden"
>
<TypeIcon type={connectionType} />
<span className="text-[10px] font-medium text-foreground truncate flex-1">
{host.name}
</span>
{showStatus && (
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: statusColor }}
/>
)}
</button>
);
})}
</div>
</div>
);
}
registerWidget<SshQuickConnectConfig>({
id: "ssh_quick_connect",
name: "Quick Connect",
description: "One-click buttons to open SSH, file, or Docker tabs",
category: "system",
icon: <Zap size={14} />,
defaultConfig: {
hostIds: [],
connectionType: "terminal",
showStatus: true,
layout: "list",
},
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: SshQuickConnectWidget,
});
export { SshQuickConnectWidget };
@@ -0,0 +1,169 @@
import { useState, useRef, useEffect } from "react";
import { Terminal as TerminalIcon, Play } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
SshTerminalConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
import { Terminal } from "@/features/terminal/Terminal";
import type { TerminalHandle } from "@/features/terminal/Terminal";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
import { WidgetTitle } from "./WidgetTitle";
function SshTerminalWidget({
widget,
config,
}: WidgetComponentProps<SshTerminalConfig>) {
const { t } = useTranslation();
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
const [loading, setLoading] = useState(true);
const [started, setStarted] = useState(false);
const terminalRef = useRef<TerminalHandle>(null);
const terminalWrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = terminalWrapRef.current;
if (!el || !started) return;
const stopWheel = (e: WheelEvent) => e.stopPropagation();
el.addEventListener("wheel", stopWheel, { capture: true, passive: true });
return () => el.removeEventListener("wheel", stopWheel, { capture: true });
}, [started]);
useEffect(() => {
if (!started) return;
const blurOnOutsideClick = (e: MouseEvent) => {
if (!terminalWrapRef.current?.contains(e.target as Node)) {
const textarea = terminalWrapRef.current?.querySelector("textarea");
textarea?.blur();
}
};
document.addEventListener("mousedown", blurOnOutsideClick, {
capture: true,
});
return () =>
document.removeEventListener("mousedown", blurOnOutsideClick, {
capture: true,
});
}, [started]);
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]);
// auto-connect after host loads if configured
useEffect(() => {
if (!loading && host && config.autoConnect) setStarted(true);
}, [loading, host, config.autoConnect]);
if (!config.hostId) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
<TerminalIcon size={20} />
<span className="text-xs">{t("homepage.sshTerminalNoHost")}</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.sshTerminalNoHost")}
</div>
);
}
const hostConfig = {
id: host.id as unknown as number,
ip: host.ip,
port: host.sshPort ?? host.port ?? 22,
username: host.username,
authType: host.authType,
terminalConfig: host.terminalConfig,
};
if (!started) {
return (
<div
className="flex flex-col items-center justify-center w-full h-full gap-3"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex flex-col items-center gap-1">
<TerminalIcon size={16} className="text-muted-foreground/60" />
<span className="text-xs font-medium text-foreground">
{host.name || host.ip}
</span>
<span className="text-[10px] text-muted-foreground/50">
{host.username}@{host.ip}
</span>
</div>
<button
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent-brand text-white text-xs font-medium hover:opacity-90 transition-opacity"
onClick={() => setStarted(true)}
>
<Play size={11} />
{t("homepage.sshTerminalConnect")}
</button>
</div>
);
}
return (
<div
ref={terminalWrapRef}
className="flex flex-col w-full h-full overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
<WidgetTitle title={widget.title} icon={<TerminalIcon size={11} />} />
<div className="flex-1 overflow-hidden">
<CommandHistoryProvider>
<Terminal
ref={terminalRef}
hostConfig={hostConfig}
isVisible={true}
showTitle={false}
splitScreen={false}
disableAutoFocus={true}
onClose={() => setStarted(false)}
/>
</CommandHistoryProvider>
</div>
</div>
);
}
registerWidget<SshTerminalConfig>({
id: "ssh_terminal",
name: "SSH Terminal",
description: "An inline SSH terminal connected to a configured host",
category: "system",
icon: <TerminalIcon size={14} />,
defaultConfig: {
hostId: 0,
autoConnect: false,
},
defaultSize: { w: GRID_SIZE * 16, h: GRID_SIZE * 12 },
minSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
component: SshTerminalWidget,
});
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { Server } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
SystemOverviewConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getVersionInfo, getDatabaseHealth } from "@/api/system-status-api";
import { getUptime } from "@/api/dashboard-api";
import { WidgetTitle } from "./WidgetTitle";
interface InfoRowProps {
label: string;
value: string | null | undefined;
valueColor?: string;
}
function InfoRow({ label, value, valueColor }: 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 font-medium"
style={valueColor ? { color: valueColor } : undefined}
>
{value}
</span>
</div>
);
}
function SystemOverviewWidget({
widget,
config,
}: WidgetComponentProps<SystemOverviewConfig>) {
const { t } = useTranslation();
const { showVersion, showDbHealth, showUptime } = config;
const [version, setVersion] = useState<string | null>(null);
const [updateAvailable, setUpdateAvailable] = useState(false);
const [dbStatus, setDbStatus] = useState<string | null>(null);
const [uptimeFormatted, setUptimeFormatted] = useState<string | null>(null);
const fetchData = async () => {
try {
if (showVersion) {
const info = await getVersionInfo(false);
setVersion((info?.localVersion as string) ?? null);
setUpdateAvailable(Boolean(info?.updateAvailable));
}
if (showDbHealth) {
const health = await getDatabaseHealth();
setDbStatus((health?.status as string) ?? "unknown");
}
if (showUptime) {
const up = await getUptime();
setUptimeFormatted(up.formatted);
}
} catch {
/* ignore */
}
};
useEffect(() => {
fetchData();
const iv = setInterval(fetchData, 300_000);
return () => clearInterval(iv);
}, [showVersion, showDbHealth, showUptime]);
const accent =
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145";
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle
title={widget.title}
icon={<Server size={11} />}
fallback="Termix"
/>
<div className="flex flex-col gap-1.5 p-3 flex-1 overflow-auto">
{showVersion && (
<InfoRow
label={t("homepage.overviewVersion")}
value={version ?? t("homepage.loading")}
valueColor={updateAvailable ? "#f97316" : undefined}
/>
)}
{showVersion && updateAvailable && (
<InfoRow
label={t("homepage.overviewUpdate")}
value={t("homepage.overviewUpdateAvailable")}
valueColor="#f97316"
/>
)}
{showDbHealth && (
<InfoRow
label={t("homepage.overviewDatabase")}
value={dbStatus ? dbStatus.toUpperCase() : t("homepage.loading")}
valueColor={
dbStatus === "ok" || dbStatus === "healthy"
? accent
: dbStatus
? "#ef4444"
: undefined
}
/>
)}
{showUptime && (
<InfoRow
label={t("homepage.overviewUptime")}
value={uptimeFormatted ?? t("homepage.loading")}
/>
)}
</div>
</div>
);
}
registerWidget<SystemOverviewConfig>({
id: "system_overview",
name: "System Overview",
description: "Termix version, database health, and uptime at a glance",
category: "system",
icon: <Server size={14} />,
defaultConfig: { showVersion: true, showDbHealth: true, showUptime: true },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 6 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: SystemOverviewWidget,
});
export { SystemOverviewWidget };
@@ -0,0 +1,145 @@
import { useEffect, useState, useRef } from "react";
import { Clock4 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
TermixUptimeConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getUptime } from "@/api/dashboard-api";
import { WidgetTitle } from "./WidgetTitle";
function formatUptime(seconds: number): {
days: number;
hours: number;
minutes: number;
secs: number;
} {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return { days, hours, minutes, secs };
}
function TermixUptimeWidget({
widget,
config,
}: WidgetComponentProps<TermixUptimeConfig>) {
const { t } = useTranslation();
const { showDetailed } = config;
const [seconds, setSeconds] = useState<number | null>(null);
const [error, setError] = useState(false);
const baseRef = useRef<number | null>(null);
const startRef = useRef<number>(Date.now());
useEffect(() => {
getUptime()
.then((info) => {
baseRef.current = info.uptimeSeconds;
startRef.current = Date.now();
setSeconds(info.uptimeSeconds);
})
.catch(() => setError(true));
const iv = setInterval(() => {
if (baseRef.current !== null) {
const elapsed = (Date.now() - startRef.current) / 1000;
setSeconds(Math.floor(baseRef.current + elapsed));
}
}, 1000);
return () => clearInterval(iv);
}, []);
if (error) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.uptimeUnavailable")}
</div>
);
}
if (seconds === null) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.loading")}
</div>
);
}
const { days, hours, minutes, secs } = formatUptime(seconds);
const accent =
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145";
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Clock4 size={11} />} />
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-2">
<Clock4 size={14} className="text-muted-foreground" />
<div className="flex items-baseline gap-1 flex-wrap justify-center">
{days > 0 && (
<span
className="text-xl font-bold tabular-nums"
style={{ color: accent }}
>
{days}
<span className="text-xs font-normal text-muted-foreground ml-0.5">
d
</span>
</span>
)}
<span
className="text-xl font-bold tabular-nums"
style={{ color: accent }}
>
{String(hours).padStart(2, "0")}
<span className="text-xs font-normal text-muted-foreground ml-0.5">
h
</span>
</span>
<span
className="text-xl font-bold tabular-nums"
style={{ color: accent }}
>
{String(minutes).padStart(2, "0")}
<span className="text-xs font-normal text-muted-foreground ml-0.5">
m
</span>
</span>
{showDetailed && (
<span
className="text-xl font-bold tabular-nums"
style={{ color: accent }}
>
{String(secs).padStart(2, "0")}
<span className="text-xs font-normal text-muted-foreground ml-0.5">
s
</span>
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground">
{t("homepage.uptimeLabel")}
</span>
</div>
</div>
);
}
registerWidget<TermixUptimeConfig>({
id: "termix_uptime",
name: "Termix Uptime",
description: "Shows how long the Termix server has been running",
category: "system",
icon: <Clock4 size={14} />,
defaultConfig: { showDetailed: false },
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 4 },
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
component: TermixUptimeWidget,
});
export { TermixUptimeWidget };
@@ -0,0 +1,60 @@
import { Type } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type {
TextBannerConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
const FONT_SIZE_MAP = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
xl: "text-xl",
};
const FONT_WEIGHT_MAP = {
normal: "font-normal",
semibold: "font-semibold",
bold: "font-bold",
};
const TEXT_ALIGN_MAP = {
left: "text-left",
center: "text-center",
right: "text-right",
};
function TextBannerWidget({ config }: WidgetComponentProps<TextBannerConfig>) {
const { text, fontSize, textAlign, fontWeight, backgroundColor } = config;
return (
<div
className={`flex items-center w-full h-full px-3 py-2 overflow-hidden ${FONT_SIZE_MAP[fontSize]} ${FONT_WEIGHT_MAP[fontWeight]} ${TEXT_ALIGN_MAP[textAlign]}`}
style={backgroundColor ? { background: backgroundColor } : undefined}
>
<span className="whitespace-pre-wrap break-words w-full text-foreground leading-tight">
{text || "Banner text"}
</span>
</div>
);
}
registerWidget<TextBannerConfig>({
id: "text_banner",
name: "Text Banner",
description: "A styled text label for sections and headings",
category: "info",
icon: <Type size={14} />,
defaultConfig: {
text: "",
fontSize: "lg",
textAlign: "center",
fontWeight: "semibold",
},
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 3 },
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
component: TextBannerWidget,
});
export { TextBannerWidget };
@@ -0,0 +1,122 @@
import { useState, useEffect } from "react";
import { Network } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
TunnelWidgetConfig,
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 { TunnelTab } from "@/features/tunnel/TunnelTab";
import { WidgetTitle } from "./WidgetTitle";
function TunnelWidget({
widget,
config,
}: WidgetComponentProps<TunnelWidgetConfig>) {
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.enableTunnel,
);
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">
<Network 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>
);
}
// TunnelTab expects Host (ui-types) — id must be a string
const tunnelHost = {
id: String(host.id),
name: host.name,
username: host.username,
ip: host.ip,
port: host.port,
folder: host.folder ?? "",
online: host.status === "online",
cpu: null,
ram: null,
lastAccess: "",
authType: host.authType,
enableTerminal: host.enableTerminal ?? false,
enableCommandHistory: false,
enableTunnel: host.enableTunnel ?? false,
serverTunnels: [],
enableFileManager: host.enableFileManager ?? false,
enableDocker: host.enableDocker ?? false,
enableProxmox: false,
enableTmuxMonitor: false,
enableSsh: host.enableSsh ?? true,
enableRdp: host.enableRdp ?? false,
enableVnc: host.enableVnc ?? false,
enableTelnet: host.enableTelnet ?? false,
sshPort: host.sshPort ?? host.port,
rdpPort: host.rdpPort ?? 3389,
vncPort: host.vncPort ?? 5900,
telnetPort: host.telnetPort ?? 23,
quickActions: [],
};
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Network size={11} />} />
<div
className="flex-1 overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
<TunnelTab
label={host.name || host.ip}
host={tunnelHost as Parameters<typeof TunnelTab>[0]["host"]}
/>
</div>
</div>
);
}
registerWidget<TunnelWidgetConfig>({
id: "tunnel_widget",
name: "Tunnel Manager",
description: "Embedded SSH tunnel manager for a configured host",
category: "system",
icon: <Network size={14} />,
defaultConfig: { hostId: 0 },
defaultSize: { w: GRID_SIZE * 16, h: GRID_SIZE * 10 },
minSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
component: TunnelWidget,
});
@@ -0,0 +1,195 @@
import { useEffect, useState } from "react";
import {
Cloud,
CloudRain,
Sun,
CloudSnow,
Wind,
Thermometer,
} from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type {
WeatherConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { WidgetTitle } from "./WidgetTitle";
interface WttrCurrent {
temp_C: string;
FeelsLikeC: string;
weatherDesc: { value: string }[];
weatherCode: string;
}
interface WttrDay {
mintempC: string;
maxtempC: string;
hourly: { weatherDesc: { value: string }[]; weatherCode: string }[];
}
interface WttrResponse {
nearest_area: {
areaName: { value: string }[];
country: { value: string }[];
}[];
current_condition: WttrCurrent[];
weather: WttrDay[];
}
function weatherIcon(code: string, size = 20) {
const n = Number(code);
if (n === 113) return <Sun size={size} className="text-yellow-400" />;
if (n <= 119) return <Cloud size={size} className="text-gray-400" />;
if (n <= 143) return <Cloud size={size} className="text-gray-400" />;
if (n <= 266) return <CloudRain size={size} className="text-blue-400" />;
if (n <= 335) return <CloudSnow size={size} className="text-blue-200" />;
if (n <= 389) return <CloudRain size={size} className="text-blue-500" />;
return <Wind size={size} className="text-muted-foreground" />;
}
function celsiusToF(c: number) {
return Math.round((c * 9) / 5 + 32);
}
function WeatherWidget({
widget,
config,
}: WidgetComponentProps<WeatherConfig>) {
const { location, unit, showForecast } = config;
const [data, setData] = useState<WttrResponse | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
if (!location) return;
let cancelled = false;
const fetchWeather = async () => {
try {
setError(false);
const res = await fetch(
`https://wttr.in/${encodeURIComponent(location)}?format=j1`,
);
if (!res.ok) throw new Error("bad response");
const json: WttrResponse = await res.json();
if (!cancelled) setData(json);
} catch {
if (!cancelled) setError(true);
}
};
fetchWeather();
const interval = setInterval(fetchWeather, 10 * 60 * 1000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [location]);
if (!location) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60 p-3 text-center">
Configure a location in widget settings
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-destructive/70 p-3 text-center">
Could not load weather for "{location}"
</div>
);
}
if (!data) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/50">
Loading...
</div>
);
}
const current = data.current_condition[0];
const area = data.nearest_area[0];
const cityName = area?.areaName[0]?.value ?? location;
const tempC = Number(current.temp_C);
const feelsC = Number(current.FeelsLikeC);
const displayTemp = unit === "F" ? `${celsiusToF(tempC)}°F` : `${tempC}°C`;
const displayFeels = unit === "F" ? `${celsiusToF(feelsC)}°F` : `${feelsC}°C`;
const desc = current.weatherDesc[0]?.value ?? "";
const code = current.weatherCode;
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Sun size={11} />} />
<div className="flex flex-col flex-1 p-3 gap-2 overflow-auto">
{/* Current conditions */}
<div className="flex items-center gap-3 flex-1 min-h-0">
<div className="shrink-0">{weatherIcon(code, 32)}</div>
<div className="flex flex-col min-w-0">
<span className="text-2xl font-bold text-foreground leading-none">
{displayTemp}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{desc}
</span>
<span className="text-[10px] text-muted-foreground/70">
Feels like {displayFeels}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<Thermometer
size={10}
className="text-muted-foreground/60 shrink-0"
/>
<span className="text-[11px] font-semibold text-foreground truncate">
{cityName}
</span>
</div>
{/* 3-day forecast */}
{showForecast && data.weather.length >= 3 && (
<div className="flex gap-1 border-t border-border pt-2 mt-auto shrink-0">
{data.weather.slice(0, 3).map((day, i) => {
const hourly = day.hourly[4] ?? day.hourly[0];
const minC = Number(day.mintempC);
const maxC = Number(day.maxtempC);
const lo = unit === "F" ? `${celsiusToF(minC)}°` : `${minC}°`;
const hi = unit === "F" ? `${celsiusToF(maxC)}°` : `${maxC}°`;
const dayLabel = i === 0 ? "Today" : i === 1 ? "Tmrw" : "+2";
return (
<div
key={i}
className="flex-1 flex flex-col items-center gap-0.5"
>
<span className="text-[9px] text-muted-foreground/70">
{dayLabel}
</span>
{weatherIcon(hourly?.weatherCode ?? "113", 12)}
<span className="text-[9px] text-foreground">{hi}</span>
<span className="text-[9px] text-muted-foreground/60">
{lo}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
registerWidget<WeatherConfig>({
id: "weather",
name: "Weather",
description: "Live weather for any location",
category: "info",
icon: <Sun size={14} />,
defaultConfig: { location: "", unit: "C", showForecast: true },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: WeatherWidget,
});
export { WeatherWidget };
@@ -0,0 +1,20 @@
import type {
WidgetTypeDefinition,
WidgetTypeId,
} from "@/types/homepage-types";
const registry = new Map<WidgetTypeId, WidgetTypeDefinition>();
export function registerWidget<C>(def: WidgetTypeDefinition<C>): void {
registry.set(def.id, def as unknown as WidgetTypeDefinition);
}
export function getWidgetType(
id: WidgetTypeId,
): WidgetTypeDefinition | undefined {
return registry.get(id);
}
export function getAllWidgetTypes(): WidgetTypeDefinition[] {
return Array.from(registry.values());
}
@@ -0,0 +1,168 @@
import { useState } from "react";
import { Pencil, Trash2, GripVertical } from "lucide-react";
import type { CanvasWidget } from "@/types/homepage-types";
import { getWidgetType } from "./WidgetRegistry";
interface WidgetShellProps {
widget: CanvasWidget;
isLocked?: boolean;
isReadOnly?: boolean;
isDragging?: boolean;
isResizing?: boolean;
onStartDrag?: (e: React.MouseEvent, widget: CanvasWidget) => void;
onStartResize?: (e: React.MouseEvent, widget: CanvasWidget) => void;
onDelete?: (id: number) => void;
onEdit?: (id: number) => void;
onConfigUpdate?: (config: Record<string, unknown>) => void;
children?: React.ReactNode;
}
export function WidgetShell({
widget,
isLocked,
isReadOnly,
isDragging,
isResizing,
onStartDrag,
onStartResize,
onDelete,
onEdit,
onConfigUpdate,
children,
}: WidgetShellProps) {
const [hovered, setHovered] = useState(false);
const typeDef = getWidgetType(widget.typeId);
const showControls =
hovered && !isLocked && !isReadOnly && !isDragging && !isResizing;
const WidgetComponent = typeDef?.component;
return (
<div
data-widget={widget.id}
className={`absolute overflow-hidden select-none transition-shadow duration-150 ${
hovered && !isLocked && !isReadOnly
? "ring-1 ring-accent-brand/30 shadow-md"
: "ring-1 ring-border/60"
}`}
style={{
left: widget.x,
top: widget.y,
width: widget.w,
height: widget.h,
background: "var(--color-card)",
opacity: isDragging ? 0.85 : 1,
transform: isDragging ? "scale(1.02)" : undefined,
boxShadow:
isDragging || isResizing ? "0 8px 32px rgba(0,0,0,0.25)" : undefined,
cursor:
isLocked || isReadOnly ? "default" : isDragging ? "grabbing" : "grab",
}}
onWheel={(e) => {
// Find the nearest scrollable ancestor within this widget
let el = e.target as HTMLElement | null;
while (el && el !== e.currentTarget) {
const canScrollY = el.scrollHeight > el.clientHeight;
const canScrollX = el.scrollWidth > el.clientWidth;
if (canScrollY || canScrollX) {
e.stopPropagation();
break;
}
el = el.parentElement;
}
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseDown={
!isLocked && !isReadOnly ? (e) => onStartDrag?.(e, widget) : undefined
}
>
{/* Widget content */}
{children ??
(WidgetComponent ? (
<WidgetComponent
widget={widget}
config={widget.config as Record<string, unknown>}
isReadOnly={isReadOnly}
onConfigUpdate={onConfigUpdate}
/>
) : (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
Unknown widget
</div>
))}
{/* Hover controls */}
{showControls && (
<>
{/* Top-right action bar */}
<div
className="absolute top-1 right-1 flex items-center gap-0.5 z-10"
style={{ opacity: hovered ? 1 : 0, transition: "opacity 0.1s" }}
>
<div className="p-1 text-muted-foreground/40 cursor-grab">
<GripVertical size={10} />
</div>
<button
className="p-1 bg-background/90 hover:bg-background border border-border text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onEdit?.(widget.id);
}}
title="Edit"
>
<Pencil size={10} />
</button>
<button
className="p-1 bg-background/90 hover:bg-destructive border border-border text-muted-foreground hover:text-destructive-foreground transition-colors"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onDelete?.(widget.id);
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
{/* Resize handle — bottom right */}
<div
className="absolute bottom-0 right-0 w-5 h-5 cursor-se-resize z-10 flex items-end justify-end pb-0.5 pr-0.5"
onMouseDown={(e) => {
e.stopPropagation();
onStartResize?.(e, widget);
}}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
className="text-muted-foreground/50"
>
<line
x1="2"
y1="10"
x2="10"
y2="2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="6"
y1="10"
x2="10"
y2="6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,20 @@
import type { LucideIcon } from "lucide-react";
interface WidgetTitleProps {
title: string | null | undefined;
icon?: React.ReactElement<LucideIcon>;
fallback?: string;
}
export function WidgetTitle({ title, icon, fallback }: WidgetTitleProps) {
const label = title || fallback;
if (!label) return null;
return (
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 shrink-0">
{icon && <span className="text-accent-brand shrink-0">{icon}</span>}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
{label}
</span>
</div>
);
}
+12 -13
View File
@@ -361,24 +361,21 @@ function HostMetricsInner({
setViewerSessionId(result.viewerSessionId);
}
// Poll until first metrics arrive. The backend's initial SSH collection
// can take up to ~60s (queue timeout), so we wait up to 90s total.
let retry = 0;
let data: ServerMetrics | null = null;
const maxRetries = 15;
const maxRetries = 30;
while (retry < maxRetries && !cancelled) {
try {
data = await getServerMetricsById(currentHostConfig.id);
break;
} catch (error) {
retry++;
if (retry === 1) {
await new Promise((r) =>
setTimeout(r, totpVerified ? 3000 : 5000),
);
} else if (retry < maxRetries && !cancelled) {
await new Promise((r) => setTimeout(r, 2000));
} else {
throw error;
}
if (data) break;
} catch {
// non-404 error — keep retrying
}
retry++;
if (retry < maxRetries && !cancelled) {
await new Promise((r) => setTimeout(r, 3000));
}
}
if (cancelled) return;
@@ -390,6 +387,8 @@ function HostMetricsInner({
setIsLoadingMetrics(false);
logServerActivity();
}
} else {
throw new Error(t("hostMetrics.connectionFailed"));
}
pollingId = window.setInterval(async () => {
@@ -0,0 +1,222 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
import {
getMetricsHistory,
type MetricsHistoryRow,
} from "@/api/host-metrics-api";
export type HistoryRange = "1h" | "6h" | "24h" | "7d" | "30d";
interface MetricsHistorySectionProps {
hostId: number;
timeRange: HistoryRange;
}
function computeNetRates(rows: MetricsHistoryRow[]): {
rxRates: Array<number | null>;
txRates: Array<number | null>;
} {
const rxRates: Array<number | null> = [];
const txRates: Array<number | null> = [];
for (let i = 0; i < rows.length; i++) {
if (i === 0) {
rxRates.push(null);
txRates.push(null);
continue;
}
const prev = rows[i - 1];
const curr = rows[i];
const dtSec =
(new Date(curr.ts).getTime() - new Date(prev.ts).getTime()) / 1000;
if (
dtSec < 0.5 ||
prev.net_rx_bytes == null ||
curr.net_rx_bytes == null ||
prev.net_tx_bytes == null ||
curr.net_tx_bytes == null
) {
rxRates.push(null);
txRates.push(null);
continue;
}
const rxDelta = curr.net_rx_bytes - prev.net_rx_bytes;
const txDelta = curr.net_tx_bytes - prev.net_tx_bytes;
rxRates.push(rxDelta < 0 ? null : rxDelta / dtSec);
txRates.push(txDelta < 0 ? null : txDelta / dtSec);
}
return { rxRates, txRates };
}
function formatBytes(bps: number): string {
if (bps >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
if (bps >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
return `${Math.round(bps)} B/s`;
}
function maxOrZero(arr: Array<number | null>): number {
return arr.reduce<number>((m, v) => (v != null && v > m ? v : m), 0);
}
export function MetricsHistorySection({
hostId,
timeRange,
}: MetricsHistorySectionProps) {
const { t } = useTranslation();
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getMetricsHistory(hostId, { range: timeRange })
.then((res) => {
if (!cancelled) {
setRows(res.rows);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load history",
);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [hostId, timeRange]);
const timestamps = rows?.map((r) => r.ts) ?? [];
const cpuSeries: LineChartSeries = {
key: "cpu",
label: t("hostMetrics.cpuUsage"),
color: "var(--accent-brand)",
data: rows?.map((r) => r.cpu_percent) ?? [],
};
const memSeries: LineChartSeries = {
key: "mem",
label: t("hostMetrics.memoryUsage"),
color: "var(--chart-3)",
data: rows?.map((r) => r.mem_percent) ?? [],
};
const diskSeries: LineChartSeries = {
key: "disk",
label: t("hostMetrics.diskUsage"),
color: "var(--chart-4)",
data: rows?.map((r) => r.disk_percent) ?? [],
};
const { rxRates, txRates } = rows
? computeNetRates(rows)
: { rxRates: [], txRates: [] };
const netMax = Math.max(maxOrZero(rxRates), maxOrZero(txRates), 1024);
const rxSeries: LineChartSeries = {
key: "rx",
label: t("metricsHistory.download"),
color: "var(--chart-2)",
data: rxRates,
};
const txSeries: LineChartSeries = {
key: "tx",
label: t("metricsHistory.upload"),
color: "var(--chart-5)",
data: txRates,
};
const hasData = rows !== null && rows.length > 0;
const hasNetData = rxRates.some((v) => v !== null);
if (loading) {
return (
<div className="flex flex-col gap-4 px-3 pb-3">
{["cpu", "mem", "disk", "net"].map((k) => (
<div key={k} className="border border-border bg-card p-4">
<div className="mb-3 h-3 w-24 animate-pulse rounded bg-muted" />
<div className="h-40 animate-pulse rounded bg-muted" />
</div>
))}
</div>
);
}
if (error) {
return (
<div className="mx-3 flex h-40 items-center justify-center border border-border bg-card text-sm text-destructive">
{error}
</div>
);
}
if (!hasData) {
return (
<div className="mx-3 flex h-40 items-center justify-center border border-border bg-card text-sm text-muted-foreground">
{t("metricsHistory.noData")}
</div>
);
}
return (
<div className="flex flex-col gap-4 px-3 pb-3">
<div className="border border-border bg-card p-4">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("hostMetrics.cpuUsage")}
</div>
<LineChart
series={[cpuSeries]}
timestamps={timestamps}
domain={[0, 100]}
yFormatter={(v) => `${v.toFixed(0)}%`}
height={160}
/>
</div>
<div className="border border-border bg-card p-4">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("hostMetrics.memoryUsage")}
</div>
<LineChart
series={[memSeries]}
timestamps={timestamps}
domain={[0, 100]}
yFormatter={(v) => `${v.toFixed(0)}%`}
height={160}
/>
</div>
<div className="border border-border bg-card p-4">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("hostMetrics.diskUsage")}
</div>
<LineChart
series={[diskSeries]}
timestamps={timestamps}
domain={[0, 100]}
yFormatter={(v) => `${v.toFixed(0)}%`}
height={160}
/>
</div>
<div className="border border-border bg-card p-4">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("metricsHistory.network")}
</div>
{hasNetData ? (
<LineChart
series={[rxSeries, txSeries]}
timestamps={timestamps}
domain={[0, netMax]}
yFormatter={formatBytes}
height={160}
/>
) : (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
{t("metricsHistory.noData")}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
export type HistoryTab = "live" | "1h" | "6h" | "24h" | "7d";
const TABS: HistoryTab[] = ["live", "1h", "6h", "24h", "7d"];
export function CardTimeTabs({
value,
onChange,
}: {
value: HistoryTab;
onChange: (tab: HistoryTab) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-0.5">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => onChange(tab)}
className={cn(
"px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest transition-colors border",
value === tab
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{tab === "live" ? t("hostMetrics.tabLive") : tab}
</button>
))}
</div>
);
}
+103 -23
View File
@@ -1,51 +1,131 @@
import { useState, useEffect } from "react";
import { Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
import { MetricCard } from "./MetricCard";
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
import {
getMetricsHistory,
type MetricsHistoryRow,
} from "@/api/host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
function computeChartData(rows: MetricsHistoryRow[]) {
return {
data: rows.map((r) => r.cpu_percent),
timestamps: rows.map((r) => r.ts),
};
}
export function CpuCard({
metrics,
history,
hostId,
}: {
metrics: ServerMetrics | null;
history: number[];
hostId: number | null;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (activeTab === "live" || hostId == null) return;
let cancelled = false;
setLoading(true);
setRows(null);
getMetricsHistory(hostId, { range: activeTab })
.then((res) => {
if (!cancelled) {
setRows(res.rows);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [activeTab, hostId]);
const percent = metrics?.cpu?.percent ?? null;
const cores = metrics?.cpu?.cores ?? null;
const load = metrics?.cpu?.load ?? null;
const series: LineChartSeries[] = [
{
key: "cpu",
label: t("hostMetrics.cpuUsage"),
color: "var(--accent-brand)",
data: rows?.map((r) => r.cpu_percent) ?? [],
},
];
const timestamps = rows?.map((r) => r.ts) ?? [];
return (
<MetricCard
title={t("hostMetrics.cpuUsage")}
icon={<Cpu className="size-3.5" />}
action={
hostId != null ? (
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
) : undefined
}
>
<div className="flex h-full flex-col gap-3">
<div className="flex items-center gap-4">
<RadialGauge
value={percent}
caption={
cores !== null
? t("hostMetrics.cpuCores", { count: cores })
: undefined
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{load && (
<div className="grid grid-cols-3 gap-2">
<MiniStat caption="1m" value={load[0].toFixed(2)} />
<MiniStat caption="5m" value={load[1].toFixed(2)} />
<MiniStat caption="15m" value={load[2].toFixed(2)} />
<div className="flex flex-col gap-3">
{activeTab === "live" && (
<div className="flex items-center gap-4">
<RadialGauge
value={percent}
caption={
cores !== null
? t("hostMetrics.cpuCores", { count: cores })
: undefined
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{load && (
<div className="grid grid-cols-3 gap-2">
<MiniStat caption="1m" value={load[0].toFixed(2)} />
<MiniStat caption="5m" value={load[1].toFixed(2)} />
<MiniStat caption="15m" value={load[2].toFixed(2)} />
</div>
)}
<Sparkline
data={[...history, percent ?? 0]}
domain={[0, 100]}
height={48}
/>
</div>
</div>
)}
{activeTab !== "live" && (
<>
{loading && (
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
{t("common.loading")}
</div>
)}
<Sparkline
data={[...history, percent ?? 0]}
domain={[0, 100]}
height={48}
/>
</div>
</div>
{!loading && rows !== null && rows.length === 0 && (
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
{t("metricsHistory.noData")}
</div>
)}
{!loading && rows !== null && rows.length > 0 && (
<LineChart
series={series}
timestamps={timestamps}
domain={[0, 100]}
yFormatter={(v) => `${v.toFixed(0)}%`}
height={160}
/>
)}
</>
)}
</div>
</MetricCard>
);

Some files were not shown because too many files have changed in this diff Show More