mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
release-2.5.0 (#994)
* feat(sshid) - sshid.io equivalent for termix (#919) * feat(ssh-id): database schema, migrations and field encryption Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys stored plaintext for the unauthenticated resolver; CA private key registered for per-user field encryption), with UNIQUE(user_id), an index on ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations. * feat(ssh-id): backend API — resolver, key management, CA and certificates Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver (+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store + noindex headers on every resolver response including early 404s. Authenticated management: claim/rename/delete handle, add/import/generate/enable/delete keys, and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate issuance. Audit logging on all mutations; UNIQUE races map to a precise 409. Unit tests for key parsing and certificate signing (ssh-keygen-validated). * feat(ssh-id): frontend panel, API client and i18n SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL + curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with server trust command, and per-key certificate issuance. API client re-exported through main-axios.ts; all strings i18n'd. * style(ssh-id): align panel and resolver page with Termix theme - Rebuild the SSH ID sidebar panel with the theme's square components (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards; use accent-brand and destructive tokens rather than raw red/green. - Fix panel scrolling: move overflow to a block scroll container so the cards keep their natural height instead of being clipped. - Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix dark theme: square corners, #18181b/#303032 palette, #f59145 accent, uppercase section labels. - Tidy copy: 'Save To Credentials' label, drop the redundant generate intro, and correct the generate tooltip (the key is stored when saving to vault). * feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923) Bumps the dev-patch-updates group with 11 updates: | Package | From | To | | --- | --- | --- | | [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` | | [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` | | [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` | | [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` | Updates `@codemirror/search` from 6.7.0 to 6.7.1 - [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/search/commits) Updates `@codemirror/view` from 6.43.0 to 6.43.1 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite) Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8) Updates `@vitest/ui` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui) Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3 - [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases) - [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md) - [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3) Updates `lint-staged` from 17.0.7 to 17.0.8 - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8) Updates `prettier` from 3.8.3 to 3.8.4 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4) Updates `sharp` from 0.35.1 to 0.35.2 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2) Updates `tailwindcss` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss) Updates `vitest` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: "@codemirror/search" dependency-version: 6.7.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: eslint-plugin-react-refresh dependency-version: 0.5.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: lint-staged dependency-version: 17.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: prettier dependency-version: 3.8.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump nanoid in the prod-patch-updates group (#925) Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid). Updates `nanoid` from 5.1.11 to 5.1.15 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15) --- updated-dependencies: - dependency-name: nanoid dependency-version: 5.1.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the major-updates group with 5 updates (#926) Bumps the major-updates group with 5 updates: | Package | From | To | | --- | --- | --- | | [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` | | [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` | | [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` | Updates `js-yaml` from 4.2.0 to 5.0.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0) Updates `@eslint/js` from 9.39.4 to 10.0.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js) Updates `@types/node` from 25.9.2 to 26.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `concurrently` from 9.2.1 to 10.0.3 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3) Updates `eslint` from 9.39.4 to 10.5.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@eslint/js" dependency-version: 10.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@types/node" dependency-version: 26.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: concurrently dependency-version: 10.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: eslint dependency-version: 10.5.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(ssh): add HashiCorp Vault SSH signer authentication * fix: small fixes to vault feature to align with Termix codebase * chore: add view docs links for vault/termix id * fix: file upload fails with 400 and missing schema migrations on upgrade (#929) Two bugs introduced in v2.4.1: 1. uploadFileStream uses fileManagerApi.post() which triggers axios's transformRequest to JSON-serialize the FormData because the instance default Content-Type is application/json. Change to postForm() which sets Content-Type: multipart/form-data so the browser XHR sends the correct multipart body with boundary. 2. Two schema items added to schema.ts were not included in migrateSchema() in db/index.ts, causing 500 errors on existing installations upgrading from v2.4.0: - user_preferences.status_color_scheme (no such column) - dashboard_service_links table (no such table) Fixes #928 Co-authored-by: sash <sash@fominykh.io> * fix: support PuTTY PPK ssh keys (#930) * fix: chunk large file manager uploads (#932) * fix: route dashboard hosts by protocol (#934) * fix: resolve tunnel endpoints reliably (#935) * Fix Electron OIDC browser auth failures (#936) * Allow RDP connections without stored credentials (#937) * Sync role credential shares for OIDC users (#938) * Fix terminal link dialog layering (#940) * Confirm large files before opening editor (#942) * Confirm closing active host connections (#943) * Preserve file path case in file manager UI (#941) * fix: preserve unicode guacamole tokens (#933) * Persist VNC authentication settings (#944) * Fix Guacamole websocket base path (#946) * Promote file manager terminals to tabs (#939) * Guard Guacamole disconnect during startup (#945) * chore: increment ver * feat: bitwarden ssh agent integration * feat: serial connections support * fix: various small bug fixes * feat: open all sessions in a folder and terminal custom theme color support * feat: cross host file manager clipboard and several small bug fixes * feat: tailscale/wireguard support and added a new status state for when backend is checking status * feat: grafana like server stats history, new alert system, ntfy/webhook support * feat: new grid and widget based homepage function * feat: new donate button in dashboard * fix: alert ui incorrectly using termix css and fixed issue with alert system not loading * chore: fix ci checks (#966) * Fix dashboard service link creation (#950) * Fix jump host SOCKS5 proxy selection (#951) * Fix jump host SOCKS5 proxy selection * fix: type jump host socks proxy config * Fix tmux detection path handling (#949) * Support GUACD_URL environment config (#952) * Retry autostart tunnel host fetches (#953) * Retry autostart tunnel host fetches * chore: format tunnel route * Fix PUID html ownership in Docker entrypoint (#954) * feat: allow custom tunnel endpoints (#977) * fix: skip metrics start for non-ssh hosts (#976) * Fix Proxmox import auth fallback (#956) * Fix Proxmox import auth fallback * chore: format proxmox import auth * Fix SSH heading syntax highlighting (#955) * Fix SSH heading syntax highlighting * chore: format terminal highlighter * Initialize auth before fullscreen terminal routes (#957) * Add WebAuthn passkey authentication (#959) * chore: add Biome tooling (#965) * chore: add biome tooling * chore: support tailwind syntax in biome * Fix VNC required argument handshake (#968) * Prioritize host results in command palette search (#969) * Prevent sidebar host hover layout shift (#970) * Add terminal font zoom with mouse wheel (#971) * Add host temperature metrics card (#972) * Make file downloads reliable in desktop app (#973) * Add app rail hover expansion setting (#974) * fix: use correct translation key for nav.close (#964) * fix(tunnel): skip endpoint credential validation for direct tunnels (#963) * fix: SSH port connection bug (#975) * fix: chunked upload for files >=1.5GB to bypass browser ArrayBuffer limit (#948) * feat: support SSH agent auth across SSH features (#960) * feat: add Podman container runtime support (#958) * chore: update readme and release notes * fix: stabilize Windows app icon (#978) * Add safe host sharing export (#979) * Add external editor support for file manager (#985) * Rework SSH credential password handling (#984) * Support password fallback for SSH key credentials * Complete SSH credential password fallback * Fix runtime base path for auth callbacks (#982) * Fix TUI terminal output highlighting (#983) Co-authored-by: LukeGus <bugattiguy527@gmail.com> * Add app fullscreen mode (#993) * Fix host metrics startup polling (#986) * chore: update release notes * fix: line chart text overlap * chore: update RELEASE_NOTES.md * chore: add donation goal to readme * chore: update readme * fix: hide full-screen button in electron app * fix: failed unit tests * chore: lint, format, and bump version to 2.5.0 * chore: remove timeout from crowdin translate * chore: sync Crowdin translations for 2.5.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: DivByZero <mr.oplus@yahoo.fr> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com> Co-authored-by: sash <sash@fominykh.io> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
AlertFeedConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getAlertFirings, acknowledgeAlertFiring } from "@/api/alerts-api";
|
||||
import type { AlertFiring } from "@/api/alerts-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function relativeTime(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function SeverityIcon({ severity }: { severity: AlertFiring["severity"] }) {
|
||||
if (severity === "critical")
|
||||
return <AlertCircle size={11} className="shrink-0 text-red-500" />;
|
||||
if (severity === "warning")
|
||||
return <AlertTriangle size={11} className="shrink-0 text-amber-500" />;
|
||||
return <Info size={11} className="shrink-0 text-blue-400" />;
|
||||
}
|
||||
|
||||
function AlertFeedWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<AlertFeedConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { maxItems, showAcknowledged } = config;
|
||||
const [firings, setFirings] = useState<AlertFiring[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const data = await getAlertFirings({ limit: maxItems * 2 });
|
||||
const filtered = showAcknowledged
|
||||
? data
|
||||
: data.filter((f) => !f.acknowledged);
|
||||
setFirings(filtered.slice(0, maxItems));
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, 30_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [maxItems, showAcknowledged]);
|
||||
|
||||
const handleAck = async (id: number) => {
|
||||
try {
|
||||
await acknowledgeAlertFiring(id);
|
||||
setFirings((prev) =>
|
||||
prev.map((f) => (f.id === id ? { ...f, acknowledged: true } : f)),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (firings.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||
<CheckCircle size={20} className="text-green-500" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("homepage.allClear")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || t("homepage.widgetAlertFeedName")}
|
||||
icon={<Bell size={11} />}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{firings.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className={`flex items-start gap-2 px-2 py-1.5 border-b border-border/30 ${f.acknowledged ? "opacity-50" : ""}`}
|
||||
>
|
||||
<SeverityIcon severity={f.severity} />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[10px] font-semibold text-foreground truncate">
|
||||
{f.ruleName ?? f.message}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{f.hostName} · {relativeTime(f.firedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{!f.acknowledged && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAck(f.id);
|
||||
}}
|
||||
className="p-0.5 text-muted-foreground hover:text-green-500 transition-colors shrink-0"
|
||||
title={t("homepage.acknowledgeAlert")}
|
||||
>
|
||||
<Check size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<AlertFeedConfig>({
|
||||
id: "alert_feed",
|
||||
name: "Alert Feed",
|
||||
description: "Live feed of active alert firings",
|
||||
category: "monitoring",
|
||||
icon: <Bell size={14} />,
|
||||
defaultConfig: { maxItems: 10, showAcknowledged: false },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: AlertFeedWidget,
|
||||
});
|
||||
|
||||
export { AlertFeedWidget };
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Bookmark, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
BookmarkListConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
function BookmarkListWidget({
|
||||
config,
|
||||
widget,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<BookmarkListConfig>) {
|
||||
const { links } = config;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden">
|
||||
{widget.title && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border shrink-0">
|
||||
<Bookmark size={11} className="text-accent-brand shrink-0" />
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
{widget.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto p-3 gap-1 flex flex-col">
|
||||
{links.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
No bookmarks
|
||||
</span>
|
||||
)}
|
||||
{links.map((link, i) =>
|
||||
isReadOnly ? (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 text-xs text-foreground py-1"
|
||||
>
|
||||
<Bookmark size={10} className="text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{link.label || link.url}</span>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs text-foreground hover:text-primary py-1 no-underline group"
|
||||
>
|
||||
<Bookmark size={10} className="text-muted-foreground shrink-0" />
|
||||
<span className="truncate flex-1">{link.label || link.url}</span>
|
||||
<ExternalLink
|
||||
size={8}
|
||||
className="text-muted-foreground/0 group-hover:text-muted-foreground/60 shrink-0"
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<BookmarkListConfig>({
|
||||
id: "bookmark_list",
|
||||
name: "Bookmarks",
|
||||
description: "A list of quick links",
|
||||
category: "links",
|
||||
icon: <Bookmark size={14} />,
|
||||
defaultConfig: { links: [] },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 10 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: BookmarkListWidget,
|
||||
});
|
||||
|
||||
export { BookmarkListWidget };
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
CalendarConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getMonthData(year: number, month: number, startOnMonday: boolean) {
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const offset = startOnMonday ? (firstDay === 0 ? 6 : firstDay - 1) : firstDay;
|
||||
return { daysInMonth, offset };
|
||||
}
|
||||
|
||||
function CalendarWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CalendarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { startOnMonday } = config;
|
||||
const [now, setNow] = useState(new Date());
|
||||
const [viewYear, setViewYear] = useState(now.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth());
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => setNow(new Date()), 60_000);
|
||||
return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const { daysInMonth, offset } = getMonthData(
|
||||
viewYear,
|
||||
viewMonth,
|
||||
startOnMonday,
|
||||
);
|
||||
const today = now;
|
||||
const isCurrentMonth =
|
||||
viewYear === today.getFullYear() && viewMonth === today.getMonth();
|
||||
|
||||
const dayNames = startOnMonday
|
||||
? [
|
||||
t("homepage.calMon"),
|
||||
t("homepage.calTue"),
|
||||
t("homepage.calWed"),
|
||||
t("homepage.calThu"),
|
||||
t("homepage.calFri"),
|
||||
t("homepage.calSat"),
|
||||
t("homepage.calSun"),
|
||||
]
|
||||
: [
|
||||
t("homepage.calSun"),
|
||||
t("homepage.calMon"),
|
||||
t("homepage.calTue"),
|
||||
t("homepage.calWed"),
|
||||
t("homepage.calThu"),
|
||||
t("homepage.calFri"),
|
||||
t("homepage.calSat"),
|
||||
];
|
||||
|
||||
const monthLabel = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(new Date(viewYear, viewMonth, 1));
|
||||
|
||||
const cells: (number | null)[] = [
|
||||
...Array(offset).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11);
|
||||
setViewYear((y) => y - 1);
|
||||
} else setViewMonth((m) => m - 1);
|
||||
};
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0);
|
||||
setViewYear((y) => y + 1);
|
||||
} else setViewMonth((m) => m + 1);
|
||||
};
|
||||
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden select-none">
|
||||
<WidgetTitle title={widget.title} icon={<CalendarDays size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</button>
|
||||
<span className="text-[10px] font-semibold text-foreground">
|
||||
{monthLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0 shrink-0">
|
||||
{dayNames.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="text-center text-[8px] font-semibold text-muted-foreground/70 uppercase py-0.5"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0 flex-1 content-start">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e-${i}`} />;
|
||||
const isToday = isCurrentMonth && day === today.getDate();
|
||||
return (
|
||||
<div
|
||||
key={`d-${day}`}
|
||||
className="flex items-center justify-center aspect-square"
|
||||
>
|
||||
<span
|
||||
className="text-[9px] font-medium w-5 h-5 flex items-center justify-center rounded-full"
|
||||
style={
|
||||
isToday
|
||||
? { background: accent, color: "#fff" }
|
||||
: { color: "var(--text-primary, currentColor)" }
|
||||
}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CalendarConfig>({
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
description: "A month calendar with today highlighted",
|
||||
category: "info",
|
||||
icon: <CalendarDays size={14} />,
|
||||
defaultConfig: { startOnMonday: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 9 },
|
||||
minSize: { w: GRID_SIZE * 7, h: GRID_SIZE * 7 },
|
||||
component: CalendarWidget,
|
||||
});
|
||||
|
||||
export { CalendarWidget };
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { ClockConfig, WidgetComponentProps } from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function ClockWidget({ widget, config }: WidgetComponentProps<ClockConfig>) {
|
||||
const { timezone, showSeconds, format } = config;
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...(showSeconds ? { second: "2-digit" } : {}),
|
||||
hour12: format === "12h",
|
||||
...(timezone ? { timeZone: timezone } : {}),
|
||||
};
|
||||
|
||||
const dateOpts: Intl.DateTimeFormatOptions = {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(timezone ? { timeZone: timezone } : {}),
|
||||
};
|
||||
|
||||
const timeStr = now.toLocaleTimeString(undefined, opts);
|
||||
const dateStr = now.toLocaleDateString(undefined, dateOpts);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden select-none">
|
||||
<WidgetTitle title={widget.title} icon={<Clock size={11} />} />
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-3 overflow-auto">
|
||||
<span className="text-2xl font-bold tabular-nums text-accent-brand">
|
||||
{timeStr}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{dateStr}</span>
|
||||
{timezone && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{timezone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ClockConfig>({
|
||||
id: "clock",
|
||||
name: "Clock",
|
||||
description: "A live clock with configurable timezone",
|
||||
category: "info",
|
||||
icon: <Clock size={14} />,
|
||||
defaultConfig: { showSeconds: true, format: "24h" },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 5 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: ClockWidget,
|
||||
});
|
||||
|
||||
export { ClockWidget };
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Timer } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
CountdownConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function decompose(ms: number): {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const days = Math.floor(total / 86400);
|
||||
const hours = Math.floor((total % 86400) / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
return { days, hours, minutes, seconds };
|
||||
}
|
||||
|
||||
function UnitBlock({ value, label }: { value: number; label: string }) {
|
||||
const accent = getAccentColor();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums leading-none"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{String(value).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountdownWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CountdownConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { targetDate, label, showDays, showHours } = config;
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
const [past, setPast] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const diff = new Date(targetDate).getTime() - Date.now();
|
||||
if (diff <= 0) {
|
||||
setRemaining(0);
|
||||
setPast(true);
|
||||
} else {
|
||||
setRemaining(diff);
|
||||
setPast(false);
|
||||
}
|
||||
};
|
||||
update();
|
||||
const iv = setInterval(update, 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [targetDate]);
|
||||
|
||||
const titleBar = (
|
||||
<WidgetTitle title={widget.title} icon={<Timer size={11} />} />
|
||||
);
|
||||
|
||||
if (!targetDate) {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex items-center justify-center flex-1 text-xs text-muted-foreground">
|
||||
{t("homepage.countdownNoDate")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (past) {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1">
|
||||
<Timer size={16} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("homepage.countdownPast")}
|
||||
</span>
|
||||
{label && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { days, hours, minutes, seconds } = decompose(remaining);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-2 p-2">
|
||||
{label && (
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||||
{showDays && (
|
||||
<UnitBlock value={days} label={t("homepage.countdownDays")} />
|
||||
)}
|
||||
{showHours && (
|
||||
<UnitBlock value={hours} label={t("homepage.countdownHours")} />
|
||||
)}
|
||||
<UnitBlock value={minutes} label={t("homepage.countdownMinutes")} />
|
||||
<UnitBlock value={seconds} label={t("homepage.countdownSeconds")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CountdownConfig>({
|
||||
id: "countdown",
|
||||
name: "Countdown",
|
||||
description: "Counts down to a target date and time",
|
||||
category: "info",
|
||||
icon: <Timer size={14} />,
|
||||
defaultConfig: { targetDate: "", label: "", showDays: true, showHours: true },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 5 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: CountdownWidget,
|
||||
});
|
||||
|
||||
export { CountdownWidget };
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Braces } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
CustomApiConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { homepageApi } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function resolvePath(obj: unknown, path: string): unknown {
|
||||
return path.split(".").reduce((acc: unknown, key) => {
|
||||
if (acc && typeof acc === "object")
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
return undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
function resolveArray(
|
||||
obj: unknown,
|
||||
path: string | undefined,
|
||||
): unknown[] | null {
|
||||
if (!path) {
|
||||
if (Array.isArray(obj)) return obj as unknown[];
|
||||
return null;
|
||||
}
|
||||
const val = resolvePath(obj, path);
|
||||
return Array.isArray(val) ? (val as unknown[]) : null;
|
||||
}
|
||||
|
||||
async function fetchProxy(url: string, ttl: number): Promise<unknown> {
|
||||
const res = await homepageApi.get("/proxy", { params: { url, ttl } });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function CustomApiWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CustomApiConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
url,
|
||||
displayField,
|
||||
label,
|
||||
unit,
|
||||
refreshInterval,
|
||||
displayMode,
|
||||
jsonPath,
|
||||
} = config;
|
||||
const [data, setData] = useState<unknown>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const interval = Math.max(10, refreshInterval || 60);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!url) return;
|
||||
try {
|
||||
const result = await fetchProxy(url, interval);
|
||||
setData(result);
|
||||
setError(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, interval * 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [url, interval]);
|
||||
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.customApiNoUrl")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-red-400">
|
||||
{t("homepage.customApiError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const titleBar = (
|
||||
<WidgetTitle title={widget.title} icon={<Braces size={11} />} />
|
||||
);
|
||||
|
||||
if (displayMode === "value") {
|
||||
const val = displayField ? resolvePath(data, displayField) : data;
|
||||
const display =
|
||||
typeof val === "object" ? JSON.stringify(val) : String(val ?? "—");
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-2">
|
||||
{label && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{unit && (
|
||||
<span className="text-[10px] text-muted-foreground">{unit}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayMode === "table") {
|
||||
const arr = resolveArray(data, jsonPath);
|
||||
if (!arr) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.customApiNotArray")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
<table className="w-full text-[9px]">
|
||||
<tbody>
|
||||
{arr.slice(0, 50).map((row, i) => {
|
||||
if (typeof row !== "object" || row === null) {
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="py-0.5 pr-2 text-foreground">
|
||||
{String(row)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr key={i} className="border-b border-border/30">
|
||||
{Object.entries(row as Record<string, unknown>)
|
||||
.slice(0, 4)
|
||||
.map(([k, v]) => (
|
||||
<td key={k} className="py-0.5 pr-2">
|
||||
<span className="text-muted-foreground">{k}: </span>
|
||||
<span className="text-foreground">
|
||||
{String(v ?? "")}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// json mode
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
<pre className="text-[9px] text-foreground whitespace-pre-wrap break-all leading-relaxed font-mono">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CustomApiConfig>({
|
||||
id: "custom_api",
|
||||
name: "Custom API",
|
||||
description: "Fetch and display data from any JSON API",
|
||||
category: "info",
|
||||
icon: <Braces size={14} />,
|
||||
defaultConfig: { url: "", refreshInterval: 60, displayMode: "value" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: CustomApiWidget,
|
||||
});
|
||||
|
||||
export { CustomApiWidget };
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Link, ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
DashboardLinksConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getServiceLinks } from "@/api/dashboard-api";
|
||||
import type { ServiceLink } from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getFaviconUrl(url: string): string {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?sz=32&domain_url=${encodeURIComponent(domain)}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function LinkTile({
|
||||
link,
|
||||
showIcons,
|
||||
isReadOnly,
|
||||
}: {
|
||||
link: ServiceLink;
|
||||
showIcons: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const faviconUrl = showIcons ? getFaviconUrl(link.url) : null;
|
||||
|
||||
const content = (
|
||||
<div className="group flex items-center gap-2 px-2 py-1.5 hover:bg-muted/40 transition-colors overflow-hidden border-b border-border/30">
|
||||
{showIcons &&
|
||||
(faviconUrl && !imgFailed ? (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="w-4 h-4 shrink-0 object-contain"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<ExternalLink size={12} className="shrink-0 text-muted-foreground" />
|
||||
))}
|
||||
<span className="text-[10px] font-medium text-foreground truncate flex-1">
|
||||
{link.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !link.url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardLinksWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<DashboardLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { showIcons, columns, maxItems } = config;
|
||||
const [links, setLinks] = useState<ServiceLink[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getServiceLinks()
|
||||
.then((data) => setLinks(maxItems ? data.slice(0, maxItems) : data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [maxItems]);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
if (links.length === 0)
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noDashboardLinks")}
|
||||
</div>
|
||||
);
|
||||
|
||||
const colClass =
|
||||
columns === 1
|
||||
? "grid-cols-1"
|
||||
: columns === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-3";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Link size={11} />} />
|
||||
<div className={`grid ${colClass} flex-1 overflow-auto content-start`}>
|
||||
{links.map((link) => (
|
||||
<LinkTile
|
||||
key={link.id}
|
||||
link={link}
|
||||
showIcons={showIcons}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<DashboardLinksConfig>({
|
||||
id: "dashboard_links",
|
||||
name: "Dashboard Links",
|
||||
description: "Shows your saved dashboard service links",
|
||||
category: "links",
|
||||
icon: <Link size={14} />,
|
||||
defaultConfig: { showIcons: true, columns: 1 },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: DashboardLinksWidget,
|
||||
});
|
||||
|
||||
export { DashboardLinksWidget };
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Container } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
DockerActivityConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getRecentActivity } from "@/api/dashboard-api";
|
||||
import type { RecentActivityItem } from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function relativeTime(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function DockerActivityWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<DockerActivityConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { maxItems, showHostName } = config;
|
||||
const [items, setItems] = useState<RecentActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const data = await getRecentActivity(maxItems * 5);
|
||||
setItems(data.filter((i) => i.type === "docker").slice(0, maxItems));
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, 30_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [maxItems]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noDockerActivity")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || t("homepage.widgetDockerActivityName")}
|
||||
icon={<Container size={11} />}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<Container size={10} className="shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
{showHostName && (
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{item.hostName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{relativeTime(item.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<DockerActivityConfig>({
|
||||
id: "docker_activity",
|
||||
name: "Docker Activity",
|
||||
description: "Recent Docker connection activity across your hosts",
|
||||
category: "system",
|
||||
icon: <Container size={14} />,
|
||||
defaultConfig: { maxItems: 10, showHostName: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
|
||||
component: DockerActivityWidget,
|
||||
});
|
||||
|
||||
export { DockerActivityWidget };
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
DockerWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { DockerManager } from "@/features/docker/DockerManager";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function DockerWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<DockerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const found = hosts.find(
|
||||
(h) => h.id === config.hostId && h.enableDocker,
|
||||
);
|
||||
setHost(found ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [config.hostId]);
|
||||
|
||||
if (!config.hostId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<Box size={20} />
|
||||
<span className="text-xs">{t("homepage.widgetNoHostSelected")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.widgetNoHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Box size={11} />} />
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DockerManager
|
||||
hostConfig={host as unknown as SSHHost}
|
||||
title={host.name || host.ip}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<DockerWidgetConfig>({
|
||||
id: "docker_widget",
|
||||
name: "Docker Manager",
|
||||
description: "Embedded Docker container manager for a configured host",
|
||||
category: "system",
|
||||
icon: <Box size={14} />,
|
||||
defaultConfig: { hostId: 0 },
|
||||
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
|
||||
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
component: DockerWidget,
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { FolderSearch } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
FileManagerWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { FileManager } from "@/features/file-manager/FileManager";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function FileManagerWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<FileManagerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const found = hosts.find((h) => h.id === config.hostId);
|
||||
setHost(found ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [config.hostId]);
|
||||
|
||||
if (!config.hostId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<FolderSearch size={20} />
|
||||
<span className="text-xs">{t("homepage.widgetNoHostSelected")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.widgetNoHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<FolderSearch size={11} />} />
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FileManager initialHost={host as unknown as SSHHost} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<FileManagerWidgetConfig>({
|
||||
id: "file_manager_widget",
|
||||
name: "File Manager",
|
||||
description: "Embedded SFTP file manager for a configured host",
|
||||
category: "system",
|
||||
icon: <FolderSearch size={14} />,
|
||||
defaultConfig: { hostId: 0 },
|
||||
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
|
||||
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
component: FileManagerWidget,
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Folder } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
FolderConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
function FolderWidget({ widget, config }: WidgetComponentProps<FolderConfig>) {
|
||||
const accentColor =
|
||||
config.color ||
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() ||
|
||||
"#f59145";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex flex-col"
|
||||
style={{
|
||||
background: `${accentColor}10`,
|
||||
borderTop: `2px solid ${accentColor}60`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 select-none">
|
||||
<Folder size={11} style={{ color: accentColor }} className="shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground truncate">
|
||||
{widget.title || "Folder"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<FolderConfig>({
|
||||
id: "folder",
|
||||
name: "Folder",
|
||||
description: "A background card for grouping widgets visually",
|
||||
category: "links",
|
||||
icon: <Folder size={14} />,
|
||||
defaultConfig: { isExpanded: true },
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: FolderWidget as unknown as React.ComponentType<
|
||||
WidgetComponentProps<FolderConfig>
|
||||
>,
|
||||
});
|
||||
|
||||
export { FolderWidget };
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
HostGridConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { getAllServerStatuses } from "@/api/host-metrics-status-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const color =
|
||||
status === "online"
|
||||
? getAccentColor()
|
||||
: status === "offline"
|
||||
? "#ef4444"
|
||||
: "#6b7280";
|
||||
return (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0 inline-block"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostGridWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<HostGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hostIds, showIp, columns } = config;
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [allHosts, statuses] = await Promise.all([
|
||||
getSSHHosts(),
|
||||
getAllServerStatuses().catch(
|
||||
() => ({}) as Record<number, { status: string }>,
|
||||
),
|
||||
]);
|
||||
const filtered =
|
||||
hostIds.length > 0
|
||||
? allHosts.filter((h) => hostIds.includes(h.id))
|
||||
: allHosts;
|
||||
const withStatus = filtered.map((h) => ({
|
||||
...h,
|
||||
status:
|
||||
(statuses as Record<number, { status: string }>)[h.id]?.status ??
|
||||
h.status ??
|
||||
"unknown",
|
||||
}));
|
||||
setHosts(withStatus);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, 10_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [hostIds.join(",")]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHosts")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols =
|
||||
columns === 2
|
||||
? "grid-cols-2"
|
||||
: columns === 3
|
||||
? "grid-cols-3"
|
||||
: "grid-cols-4";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<LayoutGrid size={11} />} />
|
||||
<div
|
||||
className={`grid ${gridCols} gap-1.5 p-2 flex-1 content-start overflow-auto`}
|
||||
>
|
||||
{hosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center gap-1.5 p-1.5 bg-muted/30 border border-border/40 overflow-hidden"
|
||||
>
|
||||
<StatusDot status={host.status ?? "unknown"} />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{host.name}
|
||||
</span>
|
||||
{showIp && (
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<HostGridConfig>({
|
||||
id: "host_grid",
|
||||
name: "Host Grid",
|
||||
description: "Live status overview grid for all your SSH hosts",
|
||||
category: "monitoring",
|
||||
icon: <LayoutGrid size={14} />,
|
||||
defaultConfig: { hostIds: [], showIp: false, columns: 3 },
|
||||
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: HostGridWidget,
|
||||
});
|
||||
|
||||
export { HostGridWidget };
|
||||
@@ -0,0 +1,336 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Server } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
HostStatusConfig,
|
||||
HostMetricKey,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
} from "@/api/host-metrics-status-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_METRICS: HostMetricKey[] = ["cpu", "memory"];
|
||||
|
||||
function migrateConfig(config: HostStatusConfig): HostMetricKey[] {
|
||||
if (config.shownMetrics?.length) return config.shownMetrics;
|
||||
if (config.showMetrics === false) return [];
|
||||
const metrics: HostMetricKey[] = ["cpu", "memory"];
|
||||
if (config.showDisk) metrics.push("disk");
|
||||
return metrics;
|
||||
}
|
||||
|
||||
interface MetricBarProps {
|
||||
label: string;
|
||||
value: number | null;
|
||||
sublabel?: string;
|
||||
}
|
||||
|
||||
function MetricBar({ label, value, sublabel }: MetricBarProps) {
|
||||
const pct = value ?? 0;
|
||||
const color = pct > 90 ? "#ef4444" : pct > 70 ? "#f97316" : getAccentColor();
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span className="text-right">
|
||||
{value != null ? `${Math.round(pct)}%` : "N/A"}
|
||||
{sublabel && <span className="ml-1 opacity-60">{sublabel}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{ width: `${pct}%`, background: color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InfoRowProps {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: InfoRowProps) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex justify-between text-[10px] gap-2">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className="text-foreground truncate text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n >= 1073741824) return `${(n / 1073741824).toFixed(1)} GB`;
|
||||
if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`;
|
||||
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
function HostStatusWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<HostStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const shownMetrics = migrateConfig(config);
|
||||
const { hostId } = config;
|
||||
const [hostName, setHostName] = useState<string | null>(null);
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const [metrics, setMetrics] = useState<ServerMetrics | null>(null);
|
||||
|
||||
const needsMetrics = shownMetrics.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return;
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const host = hosts.find((h) => h.id === hostId);
|
||||
if (host) setHostName(host.name);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [hostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return;
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await getServerStatusById(hostId);
|
||||
if (!cancelled) setOnline(s.status === "online");
|
||||
} catch {
|
||||
if (!cancelled) setOnline(false);
|
||||
}
|
||||
|
||||
if (needsMetrics) {
|
||||
try {
|
||||
const m = await getServerMetricsById(hostId);
|
||||
if (!cancelled) setMetrics(m);
|
||||
} catch {
|
||||
if (!cancelled) setMetrics(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
const iv = setInterval(poll, 10000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, [hostId, needsMetrics]);
|
||||
|
||||
if (!hostId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onlineColor =
|
||||
online === null ? "#6b7280" : online ? getAccentColor() : "#ef4444";
|
||||
const onlineLabel =
|
||||
online === null
|
||||
? t("common.unknown")
|
||||
: online
|
||||
? t("common.online")
|
||||
: t("common.offline");
|
||||
|
||||
const networkIfaces = metrics?.network?.interfaces ?? [];
|
||||
const totalRx = networkIfaces.reduce((sum, iface) => {
|
||||
const raw = iface.rxBytes ?? iface.rx ?? null;
|
||||
return raw ? sum + parseFloat(raw) : sum;
|
||||
}, 0);
|
||||
const totalTx = networkIfaces.reduce((sum, iface) => {
|
||||
const raw = iface.txBytes ?? iface.tx ?? null;
|
||||
return raw ? sum + parseFloat(raw) : sum;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Server size={11} />} />
|
||||
<div
|
||||
className={`flex flex-col gap-2.5 p-3 flex-1 overflow-auto ${!needsMetrics ? "justify-center" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Server size={14} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground truncate flex-1">
|
||||
{hostName ?? `Host #${hostId}`}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<span
|
||||
className="w-1.5 h-1.5 shrink-0 rounded-full"
|
||||
style={{ background: onlineColor }}
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: onlineColor }}
|
||||
>
|
||||
{onlineLabel}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{needsMetrics && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{shownMetrics.includes("cpu") && (
|
||||
<MetricBar
|
||||
label={t("homepage.metricCpu")}
|
||||
value={metrics?.cpu?.percent ?? null}
|
||||
sublabel={
|
||||
metrics?.cpu?.cores ? `${metrics.cpu.cores}c` : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("memory") && (
|
||||
<MetricBar
|
||||
label={t("homepage.metricMemory")}
|
||||
value={metrics?.memory?.percent ?? null}
|
||||
sublabel={
|
||||
metrics?.memory?.usedGiB != null &&
|
||||
metrics?.memory?.totalGiB != null
|
||||
? `${metrics.memory.usedGiB.toFixed(1)}/${metrics.memory.totalGiB.toFixed(1)} GiB`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("disk") && (
|
||||
<MetricBar
|
||||
label={t("homepage.metricDisk")}
|
||||
value={metrics?.disk?.percent ?? null}
|
||||
sublabel={
|
||||
metrics?.disk?.usedHuman && metrics?.disk?.totalHuman
|
||||
? `${metrics.disk.usedHuman}/${metrics.disk.totalHuman}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("uptime") && (
|
||||
<InfoRow
|
||||
label={t("homepage.metricUptime")}
|
||||
value={metrics?.uptime?.formatted ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("system") && metrics?.system && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
|
||||
<InfoRow
|
||||
label={t("homepage.metricOs")}
|
||||
value={metrics.system.os ?? null}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("homepage.metricKernel")}
|
||||
value={metrics.system.kernel ?? null}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("homepage.metricHostname")}
|
||||
value={metrics.system.hostname ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("network") && networkIfaces.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
|
||||
<div className="text-[10px] font-medium text-muted-foreground mb-0.5">
|
||||
{t("homepage.metricNetwork")}
|
||||
</div>
|
||||
{networkIfaces.slice(0, 3).map((iface) => (
|
||||
<div
|
||||
key={iface.name}
|
||||
className="flex justify-between text-[10px] gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 truncate max-w-[60px]">
|
||||
{iface.name}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{iface.ip || iface.state}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(totalRx > 0 || totalTx > 0) && (
|
||||
<div className="flex justify-between text-[10px] gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground">RX/TX</span>
|
||||
<span className="text-foreground">
|
||||
{formatBytes(totalRx)} / {formatBytes(totalTx)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("processes") && metrics?.processes && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">
|
||||
{t("homepage.metricProcesses")}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{metrics.processes.total ?? "?"}{" "}
|
||||
{t("homepage.metricProcessesTotal")}
|
||||
{metrics.processes.running != null
|
||||
? `, ${metrics.processes.running} ${t("homepage.metricProcessesRunning")}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
{metrics.processes.top?.slice(0, 3).map((proc) => (
|
||||
<div
|
||||
key={proc.pid}
|
||||
className="flex justify-between text-[10px] gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground truncate max-w-[80px]">
|
||||
{proc.command}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0">
|
||||
{proc.cpu}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsMetrics && !metrics && online && (
|
||||
<span className="text-[10px] text-muted-foreground/60 italic">
|
||||
{t("homepage.metricsNotAvailable")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<HostStatusConfig>({
|
||||
id: "host_status",
|
||||
name: "Host Status",
|
||||
description: "Shows live status and metrics for an SSH host",
|
||||
category: "system",
|
||||
icon: <Server size={14} />,
|
||||
defaultConfig: { hostId: 0, shownMetrics: DEFAULT_METRICS },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: HostStatusWidget,
|
||||
});
|
||||
|
||||
export { HostStatusWidget };
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Globe } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
IframeConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function IframeWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<IframeConfig>) {
|
||||
const { url, scrolling } = config;
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/50">
|
||||
<Globe size={24} />
|
||||
<span className="text-xs">Configure a URL in widget settings</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Globe size={11} />} />
|
||||
<div className="relative flex-1">
|
||||
<iframe
|
||||
src={url}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
scrolling={scrolling ? "yes" : "no"}
|
||||
className="w-full h-full border-0"
|
||||
title="Embedded content"
|
||||
/>
|
||||
{/* Block interaction in read-only mode so the canvas can still pan */}
|
||||
{isReadOnly && <div className="absolute inset-0 pointer-events-none" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<IframeConfig>({
|
||||
id: "iframe_embed",
|
||||
name: "iFrame Embed",
|
||||
description: "Embed any URL in an iframe",
|
||||
category: "info",
|
||||
icon: <Globe size={14} />,
|
||||
defaultConfig: { url: "", scrolling: true },
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: IframeWidget,
|
||||
});
|
||||
|
||||
export { IframeWidget };
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
ImageWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
const FIT_MAP = {
|
||||
contain: "object-contain",
|
||||
cover: "object-cover",
|
||||
fill: "object-fill",
|
||||
};
|
||||
|
||||
function ImageWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<ImageWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { imageUrl, fit, alt, linkUrl } = config;
|
||||
const [failed, setFailed] = useState(false);
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const titleBar = widget.title ? (
|
||||
<WidgetTitle title={widget.title} icon={<ImageIcon size={11} />} />
|
||||
) : null;
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 flex items-center justify-center overflow-hidden">
|
||||
{imageUrl && !failed ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={alt ?? ""}
|
||||
className={`w-full h-full ${FIT_MAP[fit]}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-muted-foreground/40">
|
||||
<ImageIcon size={24} />
|
||||
<span className="text-[10px]">{t("homepage.noImage")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !linkUrl) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ImageWidgetConfig>({
|
||||
id: "image_widget",
|
||||
name: "Image",
|
||||
description: "Display any image from a URL",
|
||||
category: "info",
|
||||
icon: <ImageIcon size={14} />,
|
||||
defaultConfig: { imageUrl: "", fit: "contain" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: ImageWidget,
|
||||
});
|
||||
|
||||
export { ImageWidget };
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useRef } from "react";
|
||||
import { List, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
LinkTreeConfig,
|
||||
LinkTreeLink,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function TreeLink({
|
||||
link,
|
||||
compact,
|
||||
isReadOnly,
|
||||
}: {
|
||||
link: LinkTreeLink;
|
||||
compact: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`group flex items-center gap-1.5 ${compact ? "py-0.5" : "py-1"} pl-2 pr-1 hover:bg-muted/40 transition-colors overflow-hidden`}
|
||||
>
|
||||
<ExternalLink
|
||||
size={9}
|
||||
className="shrink-0 text-muted-foreground group-hover:text-accent-brand transition-colors"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{link.label}
|
||||
</span>
|
||||
{!compact && link.description && (
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{link.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !link.url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkTreeWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<LinkTreeConfig>) {
|
||||
const { sections, compact } = config;
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
No sections configured
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<List size={11} />} />
|
||||
<div className="flex-1 overflow-auto">
|
||||
{sections.map((section, si) => (
|
||||
<div key={si}>
|
||||
{section.heading && (
|
||||
<div className="flex items-center gap-1.5 px-2 pt-2 pb-0.5 border-b border-border/30">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
{section.heading}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{section.links.map((link, li) => (
|
||||
<TreeLink
|
||||
key={li}
|
||||
link={link}
|
||||
compact={compact}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<LinkTreeConfig>({
|
||||
id: "link_tree",
|
||||
name: "Link Tree",
|
||||
description: "Grouped links organized into labeled sections",
|
||||
category: "links",
|
||||
icon: <List size={14} />,
|
||||
defaultConfig: { sections: [], compact: false },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
|
||||
component: LinkTreeWidget,
|
||||
});
|
||||
|
||||
export { LinkTreeWidget };
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
MarkdownNotesConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { updateHomepageItem } from "@/api/homepage-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function renderMarkdown(src: string): string {
|
||||
return src
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (/^### /.test(line))
|
||||
return `<h3 class="text-[11px] font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(4))}</h3>`;
|
||||
if (/^## /.test(line))
|
||||
return `<h2 class="text-xs font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(3))}</h2>`;
|
||||
if (/^# /.test(line))
|
||||
return `<h1 class="text-sm font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(2))}</h1>`;
|
||||
if (/^- /.test(line))
|
||||
return `<li class="text-[10px] ml-3 list-disc text-foreground">${inlineFormat(line.slice(2))}</li>`;
|
||||
if (/^\d+\. /.test(line))
|
||||
return `<li class="text-[10px] ml-3 list-decimal text-foreground">${inlineFormat(line.replace(/^\d+\. /, ""))}</li>`;
|
||||
if (line.trim() === "") return "<br/>";
|
||||
return `<p class="text-[10px] text-foreground leading-relaxed">${inlineFormat(line)}</p>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function inlineFormat(s: string): string {
|
||||
return esc(s)
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
||||
.replace(
|
||||
/`(.+?)`/g,
|
||||
'<code class="bg-muted px-0.5 font-mono text-[9px]">$1</code>',
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownNotesWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
onConfigUpdate,
|
||||
}: WidgetComponentProps<MarkdownNotesConfig>) {
|
||||
const { content, backgroundColor, renderMarkdown: doRender } = config;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(content);
|
||||
|
||||
const commitEdit = useCallback(async () => {
|
||||
setEditing(false);
|
||||
if (draft === content) return;
|
||||
try {
|
||||
await updateHomepageItem(widget.id, {
|
||||
config: { ...config, content: draft } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
onConfigUpdate?.({ ...config, content: draft } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [draft, content, widget.id, config, onConfigUpdate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full h-full overflow-hidden"
|
||||
style={backgroundColor ? { background: backgroundColor } : undefined}
|
||||
>
|
||||
<WidgetTitle title={widget.title} icon={<FileText size={11} />} />
|
||||
<div className="flex-1 overflow-auto p-2 relative">
|
||||
{editing ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="w-full h-full bg-transparent text-[10px] text-foreground resize-none outline-none border-none leading-relaxed"
|
||||
style={{ minHeight: "100%" }}
|
||||
/>
|
||||
) : doRender ? (
|
||||
<div
|
||||
className="text-[10px] leading-relaxed cursor-text"
|
||||
onDoubleClick={() => {
|
||||
if (!isReadOnly) setEditing(true);
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content || "") }}
|
||||
/>
|
||||
) : (
|
||||
<pre
|
||||
className="text-[10px] text-foreground leading-relaxed whitespace-pre-wrap break-words font-sans cursor-text"
|
||||
onDoubleClick={() => {
|
||||
if (!isReadOnly) setEditing(true);
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<MarkdownNotesConfig>({
|
||||
id: "markdown_notes",
|
||||
name: "Markdown Notes",
|
||||
description: "Notes with optional Markdown rendering and inline editing",
|
||||
category: "info",
|
||||
icon: <FileText size={14} />,
|
||||
defaultConfig: { content: "", renderMarkdown: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 3 },
|
||||
component: MarkdownNotesWidget,
|
||||
});
|
||||
|
||||
export { MarkdownNotesWidget };
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
MetricsChartConfig,
|
||||
MetricsChartMetric,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getMetricsHistory } from "@/api/host-metrics-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { MetricsHistoryRow } from "@/api/host-metrics-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function getMetricValue(
|
||||
row: MetricsHistoryRow,
|
||||
metric: MetricsChartMetric,
|
||||
): number | null {
|
||||
switch (metric) {
|
||||
case "cpu":
|
||||
return row.cpu_percent;
|
||||
case "memory":
|
||||
return row.mem_percent;
|
||||
case "disk":
|
||||
return row.disk_percent;
|
||||
case "net_rx":
|
||||
return row.net_rx_bytes != null ? row.net_rx_bytes / 1_000_000 : null;
|
||||
case "net_tx":
|
||||
return row.net_tx_bytes != null ? row.net_tx_bytes / 1_000_000 : null;
|
||||
}
|
||||
}
|
||||
|
||||
function getMetricLabel(
|
||||
metric: MetricsChartMetric,
|
||||
t: (k: string) => string,
|
||||
): string {
|
||||
switch (metric) {
|
||||
case "cpu":
|
||||
return t("homepage.metricCpu");
|
||||
case "memory":
|
||||
return t("homepage.metricMemory");
|
||||
case "disk":
|
||||
return t("homepage.metricDisk");
|
||||
case "net_rx":
|
||||
return "RX MB/s";
|
||||
case "net_tx":
|
||||
return "TX MB/s";
|
||||
}
|
||||
}
|
||||
|
||||
function getMetricUnit(metric: MetricsChartMetric): string {
|
||||
if (metric === "net_rx" || metric === "net_tx") return " MB/s";
|
||||
return "%";
|
||||
}
|
||||
|
||||
interface SparklineProps {
|
||||
values: number[];
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function Sparkline({ values, width, height, color }: SparklineProps) {
|
||||
if (values.length < 2) return null;
|
||||
const max = Math.max(...values, 1);
|
||||
const min = 0;
|
||||
const range = max - min;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * width;
|
||||
const y = height - ((v - min) / range) * height;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
});
|
||||
const polyPts = [`0,${height}`, ...pts, `${width},${height}`].join(" ");
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="overflow-visible"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`mg-${color.replace("#", "")}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points={polyPts} fill={`url(#mg-${color.replace("#", "")})`} />
|
||||
<polyline
|
||||
points={pts.join(" ")}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsChartWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<MetricsChartConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hostId, metric, range, showCurrentValue } = config;
|
||||
const [hostName, setHostName] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dims, setDims] = useState({ w: 0, h: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return;
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const h = hosts.find((x) => x.id === hostId);
|
||||
if (h) setHostName(h.name);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [hostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const obs = new ResizeObserver((entries) => {
|
||||
const e = entries[0]?.contentRect;
|
||||
if (e) setDims({ w: Math.floor(e.width), h: Math.floor(e.height) });
|
||||
});
|
||||
obs.observe(containerRef.current);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
const fetchData = () => {
|
||||
if (!hostId) return;
|
||||
getMetricsHistory(hostId, { range })
|
||||
.then((res) => {
|
||||
const vals = res.rows
|
||||
.map((r) => getMetricValue(r, metric))
|
||||
.filter((v): v is number => v != null);
|
||||
setValues(vals);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, 60_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [hostId, metric, range]);
|
||||
|
||||
const accent = getAccentColor();
|
||||
const current = values.length > 0 ? values[values.length - 1] : null;
|
||||
const unit = getMetricUnit(metric);
|
||||
const label = getMetricLabel(metric, t);
|
||||
|
||||
if (!hostId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<TrendingUp size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-1 shrink-0">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-semibold text-foreground truncate">
|
||||
{hostName ?? `Host #${hostId}`}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{label} · {range}
|
||||
</span>
|
||||
</div>
|
||||
{showCurrentValue && current != null && (
|
||||
<span
|
||||
className="text-sm font-bold tabular-nums shrink-0"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{metric === "net_rx" || metric === "net_tx"
|
||||
? current.toFixed(2)
|
||||
: Math.round(current)}
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="flex-1 min-h-0 relative">
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
) : values.length < 2 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.noData")}
|
||||
</div>
|
||||
) : (
|
||||
dims.w > 0 &&
|
||||
dims.h > 0 && (
|
||||
<Sparkline
|
||||
values={values}
|
||||
width={dims.w}
|
||||
height={dims.h}
|
||||
color={accent}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<MetricsChartConfig>({
|
||||
id: "metrics_chart",
|
||||
name: "Metrics Chart",
|
||||
description: "Historical CPU, memory, disk, or network chart for a host",
|
||||
category: "monitoring",
|
||||
icon: <TrendingUp size={14} />,
|
||||
defaultConfig: {
|
||||
hostId: 0,
|
||||
metric: "cpu",
|
||||
range: "1h",
|
||||
showCurrentValue: true,
|
||||
},
|
||||
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 4 },
|
||||
component: MetricsChartWidget,
|
||||
});
|
||||
|
||||
export { MetricsChartWidget };
|
||||
@@ -0,0 +1,39 @@
|
||||
import { StickyNote } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { NotesConfig, WidgetComponentProps } from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function NotesWidget({ widget, config }: WidgetComponentProps<NotesConfig>) {
|
||||
const { content, backgroundColor } = config;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full h-full overflow-hidden"
|
||||
style={{ background: backgroundColor || undefined }}
|
||||
>
|
||||
<WidgetTitle title={widget.title} icon={<StickyNote size={11} />} />
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
<pre className="text-xs text-foreground whitespace-pre-wrap break-words font-sans leading-relaxed select-text w-full">
|
||||
{content || (
|
||||
<span className="text-muted-foreground italic">Empty note...</span>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<NotesConfig>({
|
||||
id: "notes",
|
||||
name: "Notes",
|
||||
description: "A text notes widget",
|
||||
category: "info",
|
||||
icon: <StickyNote size={14} />,
|
||||
defaultConfig: { content: "" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: NotesWidget,
|
||||
});
|
||||
|
||||
export { NotesWidget };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user