release-2.5.0 (#994)

* feat(sshid) - sshid.io equivalent for termix (#919)

* feat(ssh-id): database schema, migrations and field encryption

Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys
stored plaintext for the unauthenticated resolver; CA private key registered
for per-user field encryption), with UNIQUE(user_id), an index on
ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations.

* feat(ssh-id): backend API — resolver, key management, CA and certificates

Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver
(+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store +
noindex headers on every resolver response including early 404s. Authenticated
management: claim/rename/delete handle, add/import/generate/enable/delete keys,
and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate
issuance. Audit logging on all mutations; UNIQUE races map to a precise 409.
Unit tests for key parsing and certificate signing (ssh-keygen-validated).

* feat(ssh-id): frontend panel, API client and i18n

SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL +
curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with
server trust command, and per-key certificate issuance. API client re-exported
through main-axios.ts; all strings i18n'd.

* style(ssh-id): align panel and resolver page with Termix theme

- Rebuild the SSH ID sidebar panel with the theme's square components
  (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards;
  use accent-brand and destructive tokens rather than raw red/green.
- Fix panel scrolling: move overflow to a block scroll container so the
  cards keep their natural height instead of being clipped.
- Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix
  dark theme: square corners, #18181b/#303032 palette, #f59145 accent,
  uppercase section labels.
- Tidy copy: 'Save To Credentials' label, drop the redundant generate intro,
  and correct the generate tooltip (the key is stored when saving to vault).

* feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922)

Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923)

Bumps the dev-patch-updates group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` |
| [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |


Updates `@codemirror/search` from 6.7.0 to 6.7.1
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.0 to 6.43.1
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite)

Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui)

Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3)

Updates `lint-staged` from 17.0.7 to 17.0.8
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8)

Updates `prettier` from 3.8.3 to 3.8.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

Updates `sharp` from 0.35.1 to 0.35.2
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2)

Updates `tailwindcss` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

---
updated-dependencies:
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: lint-staged
  dependency-version: 17.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sharp
  dependency-version: 0.35.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump nanoid in the prod-patch-updates group (#925)

Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid).


Updates `nanoid` from 5.1.11 to 5.1.15
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 5.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the major-updates group with 5 updates (#926)

Bumps the major-updates group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` |
| [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` |


Updates `js-yaml` from 4.2.0 to 5.0.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0)

Updates `@eslint/js` from 9.39.4 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@types/node` from 25.9.2 to 26.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `concurrently` from 9.2.1 to 10.0.3
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3)

Updates `eslint` from 9.39.4 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: concurrently
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(ssh): add HashiCorp Vault SSH signer authentication

* fix: small fixes to vault feature to align with Termix codebase

* chore: add view docs links for vault/termix id

* fix: file upload fails with 400 and missing schema migrations on upgrade (#929)

Two bugs introduced in v2.4.1:

1. uploadFileStream uses fileManagerApi.post() which triggers axios's
   transformRequest to JSON-serialize the FormData because the instance
   default Content-Type is application/json. Change to postForm() which
   sets Content-Type: multipart/form-data so the browser XHR sends the
   correct multipart body with boundary.

2. Two schema items added to schema.ts were not included in migrateSchema()
   in db/index.ts, causing 500 errors on existing installations upgrading
   from v2.4.0:
   - user_preferences.status_color_scheme (no such column)
   - dashboard_service_links table (no such table)

Fixes #928

Co-authored-by: sash <sash@fominykh.io>

* fix: support PuTTY PPK ssh keys (#930)

* fix: chunk large file manager uploads (#932)

* fix: route dashboard hosts by protocol (#934)

* fix: resolve tunnel endpoints reliably (#935)

* Fix Electron OIDC browser auth failures (#936)

* Allow RDP connections without stored credentials (#937)

* Sync role credential shares for OIDC users (#938)

* Fix terminal link dialog layering (#940)

* Confirm large files before opening editor (#942)

* Confirm closing active host connections (#943)

* Preserve file path case in file manager UI (#941)

* fix: preserve unicode guacamole tokens (#933)

* Persist VNC authentication settings (#944)

* Fix Guacamole websocket base path (#946)

* Promote file manager terminals to tabs (#939)

* Guard Guacamole disconnect during startup (#945)

* chore: increment ver

* feat: bitwarden ssh agent integration

* feat: serial connections support

* fix: various small bug fixes

* feat: open all sessions in a folder and terminal custom theme color support

* feat: cross host file manager clipboard and several small bug fixes

* feat: tailscale/wireguard support and added a new status state for when backend is checking status

* feat: grafana like server stats history, new alert system, ntfy/webhook support

* feat: new grid and widget based homepage function

* feat: new donate button in dashboard

* fix: alert ui incorrectly using termix css and fixed issue with alert system not loading

* chore: fix ci checks (#966)

* Fix dashboard service link creation (#950)

* Fix jump host SOCKS5 proxy selection (#951)

* Fix jump host SOCKS5 proxy selection

* fix: type jump host socks proxy config

* Fix tmux detection path handling (#949)

* Support GUACD_URL environment config (#952)

* Retry autostart tunnel host fetches (#953)

* Retry autostart tunnel host fetches

* chore: format tunnel route

* Fix PUID html ownership in Docker entrypoint (#954)

* feat: allow custom tunnel endpoints (#977)

* fix: skip metrics start for non-ssh hosts (#976)

* Fix Proxmox import auth fallback (#956)

* Fix Proxmox import auth fallback

* chore: format proxmox import auth

* Fix SSH heading syntax highlighting (#955)

* Fix SSH heading syntax highlighting

* chore: format terminal highlighter

* Initialize auth before fullscreen terminal routes (#957)

* Add WebAuthn passkey authentication (#959)

* chore: add Biome tooling (#965)

* chore: add biome tooling

* chore: support tailwind syntax in biome

* Fix VNC required argument handshake (#968)

* Prioritize host results in command palette search (#969)

* Prevent sidebar host hover layout shift (#970)

* Add terminal font zoom with mouse wheel (#971)

* Add host temperature metrics card (#972)

* Make file downloads reliable in desktop app (#973)

* Add app rail hover expansion setting (#974)

* fix: use correct translation key for nav.close (#964)

* fix(tunnel): skip endpoint credential validation for direct tunnels (#963)

* fix: SSH port connection bug (#975)

* fix: chunked upload for files >=1.5GB to bypass browser ArrayBuffer limit (#948)

* feat: support SSH agent auth across SSH features (#960)

* feat: add Podman container runtime support (#958)

* chore: update readme and release notes

* fix: stabilize Windows app icon (#978)

* Add safe host sharing export (#979)

* Add external editor support for file manager (#985)

* Rework SSH credential password handling (#984)

* Support password fallback for SSH key credentials

* Complete SSH credential password fallback

* Fix runtime base path for auth callbacks (#982)

* Fix TUI terminal output highlighting (#983)

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* Add app fullscreen mode (#993)

* Fix host metrics startup polling (#986)

* chore: update release notes

* fix: line chart text overlap

* chore: update RELEASE_NOTES.md

* chore: add donation goal to readme

* chore: update readme

* fix: hide full-screen button in electron app

* fix: failed unit tests

* chore: lint, format, and bump version to 2.5.0

* chore: remove timeout from crowdin translate

* chore: sync Crowdin translations for 2.5.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
Luke Gustafson
2026-06-29 13:28:26 -05:00
committed by GitHub
co-authored by dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> sash LukeGus DivByZero devdanetra Aleksandr Fominykh ZacharyZcR
parent fd27a366d0
commit 9de904dd4c
348 changed files with 125184 additions and 76537 deletions
+133
View File
@@ -0,0 +1,133 @@
import { rbacApi } from "@/main-axios";
export interface NotificationChannel {
id: number;
userId: string;
name: string;
type: "webhook" | "ntfy";
config: string;
enabled: boolean;
createdAt: string;
}
export interface AlertRule {
id: number;
userId: string;
hostId: number | null;
name: string;
enabled: boolean;
triggerType: string;
thresholdValue: number | null;
thresholdDurationSeconds: number | null;
cooldownMinutes: number;
createdAt: string;
updatedAt: string;
channelIds: number[];
}
export interface AlertFiring {
id: number;
userId: string;
ruleId: number;
hostId: number;
hostName: string;
firedAt: string;
resolvedAt: string | null;
value: number | null;
message: string;
severity: "info" | "warning" | "critical";
acknowledged: boolean;
ruleName?: string;
}
export async function getNotificationChannels(): Promise<
NotificationChannel[]
> {
const res = await rbacApi.get("/notification-channels");
return res.data;
}
export async function createNotificationChannel(
data: Partial<NotificationChannel>,
): Promise<NotificationChannel> {
const res = await rbacApi.post("/notification-channels", data);
return res.data;
}
export async function updateNotificationChannel(
id: number,
data: Partial<NotificationChannel>,
): Promise<NotificationChannel> {
const res = await rbacApi.put(`/notification-channels/${id}`, data);
return res.data;
}
export async function deleteNotificationChannel(id: number): Promise<void> {
await rbacApi.delete(`/notification-channels/${id}`);
}
export async function testNotificationChannel(id: number): Promise<void> {
await rbacApi.post(`/notification-channels/${id}/test`);
}
function mapRule(r: Record<string, unknown>): AlertRule {
return {
id: r.id as number,
userId: (r.user_id ?? r.userId) as string,
hostId: (r.host_id ?? r.hostId ?? null) as number | null,
name: r.name as string,
enabled: Boolean(r.enabled),
triggerType: (r.trigger_type ?? r.triggerType) as string,
thresholdValue: (r.threshold_value ?? r.thresholdValue ?? null) as
| number
| null,
thresholdDurationSeconds: (r.threshold_duration_seconds ??
r.thresholdDurationSeconds ??
null) as number | null,
cooldownMinutes: (r.cooldown_minutes ?? r.cooldownMinutes) as number,
createdAt: (r.created_at ?? r.createdAt) as string,
updatedAt: (r.updated_at ?? r.updatedAt) as string,
channelIds: Array.isArray(r.channels) ? (r.channels as number[]) : [],
};
}
export async function getAlertRules(): Promise<AlertRule[]> {
const res = await rbacApi.get("/alert-rules");
return (res.data as Record<string, unknown>[]).map(mapRule);
}
export async function createAlertRule(
data: Partial<AlertRule> & { channels?: number[] },
): Promise<AlertRule> {
const res = await rbacApi.post("/alert-rules", data);
return mapRule(res.data as Record<string, unknown>);
}
export async function updateAlertRule(
id: number,
data: Partial<AlertRule> & { channels?: number[] },
): Promise<AlertRule> {
const res = await rbacApi.put(`/alert-rules/${id}`, data);
return mapRule(res.data as Record<string, unknown>);
}
export async function deleteAlertRule(id: number): Promise<void> {
await rbacApi.delete(`/alert-rules/${id}`);
}
export async function getAlertFirings(opts?: {
limit?: number;
offset?: number;
acknowledged?: boolean;
}): Promise<AlertFiring[]> {
const res = await rbacApi.get("/alert-firings", { params: opts });
return (res.data as { firings: AlertFiring[] }).firings ?? res.data;
}
export async function acknowledgeAlertFiring(id: number): Promise<void> {
await rbacApi.post(`/alert-firings/${id}/acknowledge`);
}
export async function acknowledgeAllAlertFirings(): Promise<void> {
await rbacApi.post("/alert-firings/acknowledge-all");
}
+1 -1
View File
@@ -96,7 +96,7 @@ export async function getSSHHostWithCredentials(
export async function getHostPassword(
hostId: number,
field: "password" | "sudoPassword" = "password",
field: "password" | "sudoPassword" | "vncPassword" = "password",
): Promise<string | null> {
try {
const response = await sshHostApi.get(
+12 -2
View File
@@ -1,4 +1,5 @@
import { dashboardApi, handleApiError } from "@/main-axios";
import { normalizeServiceLinkUrl } from "@/lib/service-link-url";
// DASHBOARD API
// ============================================================================
@@ -105,7 +106,10 @@ export async function createServiceLink(
url: string,
): Promise<ServiceLink> {
try {
const response = await dashboardApi.post("/service-links", { label, url });
const response = await dashboardApi.post("/service-links", {
label,
url: normalizeServiceLinkUrl(url),
});
return response.data;
} catch (error) {
throw handleApiError(error, "create service link");
@@ -128,7 +132,13 @@ export async function updateServiceLink(
updates: { label?: string; url?: string },
): Promise<ServiceLink> {
try {
const response = await dashboardApi.put(`/service-links/${id}`, updates);
const response = await dashboardApi.put(`/service-links/${id}`, {
...updates,
url:
updates.url !== undefined
? normalizeServiceLinkUrl(updates.url)
: undefined,
});
return response.data;
} catch (error) {
throw handleApiError(error, "update service link");
+81
View File
@@ -0,0 +1,81 @@
import { homepageApi, handleApiError } from "@/main-axios";
import type {
HomepageItemRow,
HomepageLayoutData,
HomepageLayoutRow,
WidgetTypeId,
} from "@/types/homepage-types";
export async function getHomepageItems(): Promise<HomepageItemRow[]> {
try {
const res = await homepageApi.get("/items");
return res.data;
} catch (error) {
throw handleApiError(error, "fetch homepage items");
}
}
export async function createHomepageItem(data: {
typeId: WidgetTypeId;
title?: string | null;
config?: Record<string, unknown>;
}): Promise<HomepageItemRow> {
try {
const res = await homepageApi.post("/items", data);
return res.data;
} catch (error) {
throw handleApiError(error, "create homepage item");
}
}
export async function updateHomepageItem(
id: number,
data: {
title?: string | null;
config?: Record<string, unknown>;
},
): Promise<HomepageItemRow> {
try {
const res = await homepageApi.put(`/items/${id}`, data);
return res.data;
} catch (error) {
throw handleApiError(error, "update homepage item");
}
}
export async function deleteHomepageItem(id: number): Promise<void> {
try {
await homepageApi.delete(`/items/${id}`);
} catch (error) {
throw handleApiError(error, "delete homepage item");
}
}
export async function getHomepageLayout(): Promise<HomepageLayoutRow | null> {
try {
const res = await homepageApi.get("/layout");
return res.data;
} catch (error) {
throw handleApiError(error, "fetch homepage layout");
}
}
export async function saveHomepageLayout(
layout: HomepageLayoutData,
): Promise<HomepageLayoutRow> {
try {
const res = await homepageApi.put("/layout", layout);
return res.data;
} catch (error) {
throw handleApiError(error, "save homepage layout");
}
}
export function getHomepageFaviconUrl(url: string): string {
try {
const base = (homepageApi.defaults.baseURL ?? "").replace(/\/$/, "");
return `${base}/favicon?url=${encodeURIComponent(url)}`;
} catch {
return "";
}
}
+37
View File
@@ -1,6 +1,43 @@
import { handleApiError, statsApi } from "@/main-axios";
import type { HostMetricsLayout } from "@/types/host-metrics";
export interface MetricsHistoryRow {
ts: string;
cpu_percent: number | null;
mem_percent: number | null;
disk_percent: number | null;
net_rx_bytes: number | null;
net_tx_bytes: number | null;
}
export interface MetricsHistoryResponse {
rows: MetricsHistoryRow[];
fromTs: string;
toTs: string;
}
export async function getMetricsHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<MetricsHistoryResponse> {
const res = await statsApi.get(`/metrics/history/${hostId}`, {
params: opts,
});
return res.data as MetricsHistoryResponse;
}
export async function getMetricsHistoryRetention(): Promise<number> {
const res = await statsApi.get("/global-settings/history");
return (res.data as { metricsHistoryRetentionDays: number })
.metricsHistoryRetentionDays;
}
export async function saveMetricsHistoryRetention(days: number): Promise<void> {
await statsApi.post("/global-settings/history", {
metricsHistoryRetentionDays: days,
});
}
/**
* Host Metrics layout persistence (server-synced per user/host) + the manager
* card endpoints. All routes live under the `/host-metrics/*` prefix on the
+1
View File
@@ -89,6 +89,7 @@ export interface UserPreferences {
showHostTags?: boolean | null;
hostTrayOnClick?: boolean | null;
pinAppRail?: boolean | null;
expandAppRailOnHover?: boolean | null;
foldersCollapsed?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
+95 -9
View File
@@ -24,6 +24,27 @@ type ConnectErrorResponse = {
reason?: string;
};
function buildFileManagerUrl(path: string): string {
const baseURL = String(fileManagerApi.defaults.baseURL || "");
return `${baseURL.replace(/\/$/, "")}${path}`;
}
function triggerBlobDownload(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.style.display = "none";
document.body.appendChild(link);
link.click();
window.setTimeout(() => {
link.remove();
URL.revokeObjectURL(url);
}, 1000);
}
// SSH FILE OPERATIONS
// ============================================================================
@@ -348,8 +369,74 @@ export async function uploadSSHFile(
file: File,
hostId?: number,
userId?: string,
onChunkProgress?: (progress: {
chunkIndex: number;
totalChunks: number;
bytesSent: number;
totalBytes: number;
}) => void,
): Promise<Record<string, unknown>> {
// Browser-side safety: any single multipart body approaching 2^31 bytes (~2.14GB)
// crashes the XHR/ArrayBuffer pipeline in both Chromium (Electron) and Firefox,
// surfacing as "DOMException: File could not be read" inside FileManager-*.js.
// For larger files we slice into <=8MB chunks and upload each one separately,
// never materializing the whole file in JS memory.
const CHUNK_THRESHOLD_BYTES = 1.5 * 1024 * 1024 * 1024; // 1.5 GiB
const CHUNK_SIZE_BYTES = 8 * 1024 * 1024; // 8 MiB
try {
if (file.size > CHUNK_THRESHOLD_BYTES) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE_BYTES);
fileLogger.info("Starting chunked upload", {
operation: "file_upload_chunked_start",
fileName,
fileSize: file.size,
totalChunks,
chunkSize: CHUNK_SIZE_BYTES,
});
let bytesSent = 0;
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE_BYTES;
const end = Math.min(start + CHUNK_SIZE_BYTES, file.size);
const chunkBlob = file.slice(start, end);
const form = new FormData();
form.append("sessionId", sessionId);
form.append("path", path);
form.append("fileName", fileName);
form.append("chunkIndex", String(i));
form.append("totalChunks", String(totalChunks));
form.append("totalSize", String(file.size));
form.append("chunk", chunkBlob, fileName);
const response = await fileManagerApi.post(
"/ssh/uploadFileChunk",
form,
{ timeout: 0 },
);
bytesSent = end;
onChunkProgress?.({
chunkIndex: i,
totalChunks,
bytesSent,
totalBytes: file.size,
});
if (i === totalChunks - 1) {
fileLogger.success("Chunked upload completed", {
operation: "file_upload_chunked_complete",
fileName,
fileSize: file.size,
totalChunks,
});
return response.data;
}
}
return { message: "File uploaded successfully", chunked: true };
}
const form = new FormData();
form.append("sessionId", sessionId);
form.append("path", path);
@@ -357,9 +444,13 @@ export async function uploadSSHFile(
if (userId !== undefined) form.append("userId", userId);
form.append("file", file, fileName);
const response = await fileManagerApi.post("/ssh/uploadFileStream", form, {
timeout: 0,
});
const response = await fileManagerApi.postForm(
"/ssh/uploadFileStream",
form,
{
timeout: 0,
},
);
return response.data;
} catch (error) {
handleApiError(error, "upload SSH file");
@@ -400,12 +491,7 @@ export async function downloadSSHFileStream(
);
const blob = response.data as Blob;
const fileName = filePath.split("/").pop() || "download";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
triggerBlobDownload(blob, fileName);
}
export async function createSSHFile(
+20 -2
View File
@@ -88,6 +88,7 @@ export async function wakeOnLan(hostId: number): Promise<{ success: boolean }> {
export async function bulkImportSSHHosts(
hosts: SSHHostData[],
overwrite = false,
credentials?: Record<string, unknown>[],
): Promise<{
message: string;
success: number;
@@ -100,6 +101,7 @@ export async function bulkImportSSHHosts(
const response = await sshHostApi.post("/bulk-import", {
hosts,
overwrite,
...(credentials ? { credentials } : {}),
});
return response.data;
} catch (error) {
@@ -190,11 +192,27 @@ export async function exportSSHHostWithCredentials(
}
}
export async function exportAllSSHHosts(): Promise<{
export function exportAllSSHHosts(): Promise<{
hosts: SSHHost[];
}>;
export function exportAllSSHHosts(options: { share: true }): Promise<{
version: string;
exportedAt: string;
credentials: Record<string, unknown>[];
hosts: SSHHost[];
}>;
export async function exportAllSSHHosts(options?: {
share?: boolean;
}): Promise<{
version?: string;
exportedAt?: string;
credentials?: Record<string, unknown>[];
hosts: SSHHost[];
}> {
try {
const response = await sshHostApi.get("/db/hosts/export");
const response = await sshHostApi.get(
options?.share ? "/db/hosts/export?share=1" : "/db/hosts/export",
);
return response.data;
} catch (error) {
handleApiError(error, "export all SSH hosts");
+217
View File
@@ -0,0 +1,217 @@
import { authApi, handleApiError } from "@/main-axios";
export interface TermixIdentity {
id: number;
userId: string;
handle: string;
description: string | null;
resolverPath?: string;
createdAt: string;
updatedAt: string;
}
export interface TermixIdentityKey {
id: number;
identityId: number;
userId: string;
publicKey: string;
keyType: string;
algorithm: string;
label: string | null;
comment: string | null;
source: string;
credentialId: number | null;
enabled: boolean;
createdAt: string;
}
export interface TermixIdMe {
identity: TermixIdentity | null;
keys: TermixIdentityKey[];
}
export async function getMyTermixId(): Promise<TermixIdMe> {
try {
const response = await authApi.get("/termix-id/me");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch Termix ID");
}
}
export async function checkTermixIdHandle(
handle: string,
): Promise<{ available: boolean; valid: boolean }> {
try {
const response = await authApi.get(
`/termix-id/check/${encodeURIComponent(handle)}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "check handle");
}
}
export async function createTermixId(
handle: string,
description?: string,
): Promise<TermixIdentity> {
try {
const response = await authApi.post("/termix-id", { handle, description });
return response.data;
} catch (error) {
throw handleApiError(error, "create Termix ID");
}
}
export async function updateTermixId(data: {
handle?: string;
description?: string;
}): Promise<TermixIdentity> {
try {
const response = await authApi.put("/termix-id", data);
return response.data;
} catch (error) {
throw handleApiError(error, "update Termix ID");
}
}
export async function deleteTermixId(): Promise<void> {
try {
await authApi.delete("/termix-id");
} catch (error) {
throw handleApiError(error, "delete Termix ID");
}
}
export async function addTermixIdKey(data: {
publicKey?: string;
credentialId?: number;
label?: string;
}): Promise<TermixIdentityKey> {
try {
const response = await authApi.post("/termix-id/keys", data);
return response.data;
} catch (error) {
throw handleApiError(error, "add key");
}
}
export interface GeneratedKey {
key: TermixIdentityKey;
privateKey: string;
publicKey: string;
credentialId: number | null;
}
export async function generateTermixIdKey(
type: "ed25519" | "rsa" = "ed25519",
saveCredential = true,
): Promise<GeneratedKey> {
try {
const response = await authApi.post("/termix-id/keys/generate", {
type,
saveCredential,
});
return response.data;
} catch (error) {
throw handleApiError(error, "generate key");
}
}
export async function setTermixIdKeyEnabled(
id: number,
enabled: boolean,
): Promise<TermixIdentityKey> {
try {
const response = await authApi.patch(`/termix-id/keys/${id}`, { enabled });
return response.data;
} catch (error) {
throw handleApiError(error, "update key");
}
}
export async function deleteTermixIdKey(id: number): Promise<void> {
try {
await authApi.delete(`/termix-id/keys/${id}`);
} catch (error) {
throw handleApiError(error, "delete key");
}
}
export interface TermixIdCa {
publicKey: string;
validityDays: number;
resolverPath: string;
}
export interface IssuedCertificate {
certificate: string;
keyId: string;
validBefore: number;
principals: string[];
validityDays: number;
}
export async function getMyCa(): Promise<{ ca: TermixIdCa | null }> {
try {
const response = await authApi.get("/termix-id/ca");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch CA");
}
}
export async function createCa(validityDays?: number): Promise<TermixIdCa> {
try {
const response = await authApi.post("/termix-id/ca", { validityDays });
return response.data;
} catch (error) {
throw handleApiError(error, "create CA");
}
}
export async function rotateCa(validityDays?: number): Promise<TermixIdCa> {
try {
const response = await authApi.post("/termix-id/ca/rotate", {
validityDays,
});
return response.data;
} catch (error) {
throw handleApiError(error, "rotate CA");
}
}
export async function deleteCa(): Promise<void> {
try {
await authApi.delete("/termix-id/ca");
} catch (error) {
throw handleApiError(error, "delete CA");
}
}
export async function issueCertificate(
keyId: number,
opts: { principals?: string[]; validityDays?: number } = {},
): Promise<IssuedCertificate> {
try {
const response = await authApi.post(
`/termix-id/keys/${keyId}/certificate`,
opts,
);
return response.data;
} catch (error) {
throw handleApiError(error, "issue certificate");
}
}
export async function getLinkedCredentialIds(): Promise<{
credentialIds: number[];
}> {
try {
const response = await authApi.get("/termix-id/linked-credentials");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch linked credentials");
}
}
+60
View File
@@ -0,0 +1,60 @@
import { authApi, handleApiError } from "@/main-axios";
export interface VaultProfilePayload {
name: string;
description?: string | null;
folder?: string | null;
tags?: string[];
vaultAddr: string;
vaultNamespace?: string | null;
oidcMount?: string | null;
oidcRole?: string | null;
sshMount?: string | null;
sshRole: string;
validPrincipals?: string | null;
keyType?: string | null;
shared?: boolean;
}
export async function getVaultProfiles(): Promise<Record<string, unknown>[]> {
try {
const response = await authApi.get("/vault/profiles");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch vault profiles");
}
}
export async function createVaultProfile(
payload: VaultProfilePayload,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/vault/profiles", payload);
return response.data;
} catch (error) {
throw handleApiError(error, "create vault profile");
}
}
export async function updateVaultProfile(
id: number,
payload: Partial<VaultProfilePayload>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(`/vault/profiles/${id}`, payload);
return response.data;
} catch (error) {
throw handleApiError(error, "update vault profile");
}
}
export async function deleteVaultProfile(
id: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete(`/vault/profiles/${id}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete vault profile");
}
}
+119
View File
@@ -0,0 +1,119 @@
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/browser";
import {
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import { authApi, handleApiError, type AuthResponse } from "@/main-axios";
export type WebAuthnUserVerification = "discouraged" | "preferred" | "required";
export type WebAuthnCredentialSummary = {
id: string;
name: string;
deviceType?: string | null;
backedUp: boolean;
transports: string[];
userVerification: WebAuthnUserVerification;
createdAt: string;
lastUsedAt?: string | null;
};
type RegistrationOptionsResponse = {
options: PublicKeyCredentialCreationOptionsJSON;
challengeId: string;
};
type AuthenticationOptionsResponse = {
options: PublicKeyCredentialRequestOptionsJSON;
challengeId: string;
};
export async function listWebAuthnCredentials(): Promise<{
credentials: WebAuthnCredentialSummary[];
}> {
try {
const response = await authApi.get("/users/webauthn/credentials");
return response.data;
} catch (error) {
throw handleApiError(error, "list passkeys");
}
}
export async function registerWebAuthnCredential(
name: string,
userVerification: WebAuthnUserVerification,
): Promise<{ success: boolean }> {
try {
const optionsResponse = await authApi.post<RegistrationOptionsResponse>(
"/users/webauthn/register/options",
{ userVerification },
);
const credential = await startRegistration({
optionsJSON: optionsResponse.data.options,
});
const verifyResponse = await authApi.post(
"/users/webauthn/register/verify",
{
challengeId: optionsResponse.data.challengeId,
name,
response: credential as RegistrationResponseJSON,
},
);
return verifyResponse.data;
} catch (error) {
throw handleApiError(error, "register passkey");
}
}
export async function authenticateWithWebAuthn(
username: string,
rememberMe: boolean,
userVerification: WebAuthnUserVerification = "preferred",
): Promise<AuthResponse> {
try {
const optionsResponse = await authApi.post<AuthenticationOptionsResponse>(
"/users/webauthn/authenticate/options",
{
username: username.trim() || undefined,
userVerification,
},
);
const credential = await startAuthentication({
optionsJSON: optionsResponse.data.options,
});
const verifyResponse = await authApi.post<AuthResponse>(
"/users/webauthn/authenticate/verify",
{
challengeId: optionsResponse.data.challengeId,
rememberMe,
response: credential as AuthenticationResponseJSON,
},
);
if (verifyResponse.data.token) {
localStorage.setItem("jwt", verifyResponse.data.token);
}
return verifyResponse.data;
} catch (error) {
throw handleApiError(error, "authenticate with passkey");
}
}
export async function deleteWebAuthnCredential(
credentialId: string,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/users/webauthn/credentials/${credentialId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "delete passkey");
}
}