mirror of
https://github.com/AlchemillaHQ/Sylve.git
synced 2026-06-14 00:46:34 +03:00
chore: more dependency removals, npm: cleanup
This commit is contained in:
Vendored
+5
-2
@@ -6,5 +6,8 @@
|
||||
"window.title": "${rootName}",
|
||||
"explorer.compactFolders": false,
|
||||
"workbench.startupEditor": "none",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
"editor.formatOnSave": true,
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+5187
-6138
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,6 @@
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"layerchart": "^2.0.0-next.43",
|
||||
"mode-watcher": "^1.0.8",
|
||||
"paneforge": "^1.0.0-next.5",
|
||||
"prettier": "^3.4.2",
|
||||
@@ -67,11 +66,6 @@
|
||||
"@fontsource/noto-sans": "^5.2.7",
|
||||
"@wuchale/svelte": "^0.17.5",
|
||||
"@wuchale/vite-plugin": "^0.15.3",
|
||||
"adze": "^2.2.4",
|
||||
"axios": "^1.11.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-zoom": "^2.2.0",
|
||||
"cronstrue": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@plugin "@iconify/tailwind4";
|
||||
@source '../node_modules/layerchart/dist';
|
||||
@import '@layerstack/tailwind/core.css';
|
||||
/* @import '@layerstack/tailwind/themes/basic.css'; */
|
||||
|
||||
:root {
|
||||
--radius: 0.3rem;
|
||||
|
||||
+220
-191
@@ -12,254 +12,283 @@ import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { storage } from '$lib';
|
||||
import type { JWTClaims } from '$lib/types/auth';
|
||||
import type { APIResponse, Locales } from '$lib/types/common';
|
||||
import type { APIResponse } from '$lib/types/common';
|
||||
import { handleAPIError } from '$lib/utils/http';
|
||||
import { sha256 } from '$lib/utils/string';
|
||||
import adze from 'adze';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
async function parseJSONResponse(response: Response): Promise<any> {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json') && !contentType.includes('+json')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_e: unknown) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
authType: string,
|
||||
remember: boolean
|
||||
username: string,
|
||||
password: string,
|
||||
authType: string,
|
||||
remember: boolean
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (username === '' || password === '') {
|
||||
toast.error('Credentials are required', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
try {
|
||||
if (username === '' || password === '') {
|
||||
toast.error('Credentials are required', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authType === '') {
|
||||
toast.error('Authentication type is required', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
if (authType === '') {
|
||||
toast.error('Authentication type is required', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await axios.post('/api/auth/login', {
|
||||
username,
|
||||
password,
|
||||
authType,
|
||||
remember
|
||||
});
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
authType,
|
||||
remember
|
||||
})
|
||||
});
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
if (response.data.data?.hostname && response.data.data?.token) {
|
||||
console.log('Received login data:', response.data.data);
|
||||
const responseData = await parseJSONResponse(response);
|
||||
|
||||
storage.hostname = response.data.data.hostname;
|
||||
storage.nodeId = response.data.data.nodeId || '';
|
||||
storage.token = response.data.data.token || '';
|
||||
storage.clusterToken = response.data.data.clusterToken || '';
|
||||
if (response.status === 200 && responseData) {
|
||||
if (responseData.data?.hostname && responseData.data?.token) {
|
||||
console.log('Received login data:', responseData.data);
|
||||
|
||||
console.log('Login response:', response.data);
|
||||
return true;
|
||||
} else {
|
||||
toast.error('Invalid response received', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError;
|
||||
const data = axiosError.response?.data as APIResponse;
|
||||
handleAPIError(data);
|
||||
if (data.error) {
|
||||
if (data.error.includes('only_admin_allowed')) {
|
||||
toast.error('Only admin users can log in', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
} else {
|
||||
toast.error('Authentication failed', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast.error('Fatal error logging in, check logs!', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
storage.hostname = responseData.data.hostname;
|
||||
storage.nodeId = responseData.data.nodeId || '';
|
||||
storage.token = responseData.data.token || '';
|
||||
storage.clusterToken = responseData.data.clusterToken || '';
|
||||
|
||||
return false;
|
||||
console.log('Login response:', responseData);
|
||||
return true;
|
||||
} else {
|
||||
toast.error('Invalid response received', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = (responseData || {}) as APIResponse;
|
||||
handleAPIError(data);
|
||||
|
||||
if (data.error) {
|
||||
if (data.error.includes('only_admin_allowed')) {
|
||||
toast.error('Only admin users can log in', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
} else {
|
||||
toast.error('Authentication failed', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast.error('Authentication failed', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
toast.error('Fatal error logging in, check logs!', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (browser) {
|
||||
return storage.token;
|
||||
}
|
||||
if (browser) {
|
||||
return storage.token;
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getClusterToken(): string | null {
|
||||
if (browser) {
|
||||
return storage.clusterToken;
|
||||
}
|
||||
if (browser) {
|
||||
return storage.clusterToken;
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isTokenValid(): Promise<boolean> {
|
||||
if (!storage.token) {
|
||||
return false;
|
||||
}
|
||||
if (!storage.token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/health/basic', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${storage.token}`
|
||||
}
|
||||
});
|
||||
try {
|
||||
const response = await fetch('/api/health/basic', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${storage.token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status < 400) {
|
||||
if (response.data?.hostname) {
|
||||
storage.hostname = response.data.hostname;
|
||||
}
|
||||
if (response.data?.nodeId) {
|
||||
storage.nodeId = response.data.nodeId;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (_e: unknown) {
|
||||
return false;
|
||||
}
|
||||
const responseData = await parseJSONResponse(response);
|
||||
|
||||
return false;
|
||||
if (response.status < 400) {
|
||||
if (responseData?.hostname) {
|
||||
storage.hostname = responseData.hostname;
|
||||
}
|
||||
if (responseData?.nodeId) {
|
||||
storage.nodeId = responseData.nodeId;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (_e: unknown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function isClusterTokenValid(): Promise<boolean> {
|
||||
try {
|
||||
const clusterToken = storage.clusterToken;
|
||||
if (!clusterToken) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const clusterToken = storage.clusterToken;
|
||||
if (!clusterToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const response = await axios.get('/api/health/basic', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${clusterToken}`,
|
||||
'X-Cluster-Token': `Bearer ${clusterToken}`
|
||||
}
|
||||
});
|
||||
const response = await fetch('/api/health/basic', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${clusterToken}`,
|
||||
'X-Cluster-Token': `Bearer ${clusterToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status < 400) {
|
||||
if (response.data?.hostname) {
|
||||
// setLocalStorage('hostname', response.data.hostname);
|
||||
storage.hostname = response.data.hostname;
|
||||
}
|
||||
if (response.data?.nodeId) {
|
||||
// setLocalStorage('nodeId', response.data.nodeId);
|
||||
storage.nodeId = response.data.nodeId;
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
localStorage.removeItem('clusterToken');
|
||||
}
|
||||
} catch (_e: unknown) {
|
||||
return false;
|
||||
}
|
||||
const responseData = await parseJSONResponse(response);
|
||||
|
||||
return false;
|
||||
if (response.status < 400) {
|
||||
if (responseData?.hostname) {
|
||||
// setLocalStorage('hostname', response.data.hostname);
|
||||
storage.hostname = responseData.hostname;
|
||||
}
|
||||
if (responseData?.nodeId) {
|
||||
// setLocalStorage('nodeId', response.data.nodeId);
|
||||
storage.nodeId = responseData.nodeId;
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
localStorage.removeItem('clusterToken');
|
||||
}
|
||||
} catch (_e: unknown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function logOut(message?: string) {
|
||||
const token = storage.token;
|
||||
const token = storage.token;
|
||||
|
||||
if (token) {
|
||||
storage.oldToken = token;
|
||||
}
|
||||
if (token) {
|
||||
storage.oldToken = token;
|
||||
}
|
||||
|
||||
storage.token = '';
|
||||
storage.clusterToken = '';
|
||||
storage.hostname = '';
|
||||
storage.nodeId = '';
|
||||
storage.token = '';
|
||||
storage.clusterToken = '';
|
||||
storage.hostname = '';
|
||||
storage.nodeId = '';
|
||||
|
||||
if (browser) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('hostname');
|
||||
localStorage.removeItem('nodeId');
|
||||
localStorage.removeItem('clusterToken');
|
||||
}
|
||||
if (browser) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('hostname');
|
||||
localStorage.removeItem('nodeId');
|
||||
localStorage.removeItem('clusterToken');
|
||||
}
|
||||
|
||||
if (message) {
|
||||
toast.success(message, {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
if (message) {
|
||||
toast.success(message, {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
|
||||
goto('/', {
|
||||
replaceState: true,
|
||||
state: {
|
||||
loggedOut: true
|
||||
}
|
||||
});
|
||||
goto('/', {
|
||||
replaceState: true,
|
||||
state: {
|
||||
loggedOut: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeJWT() {
|
||||
try {
|
||||
const oldtoken = storage.oldToken;
|
||||
if (oldtoken) {
|
||||
await axios.get('/api/auth/logout', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${oldtoken}`
|
||||
}
|
||||
});
|
||||
try {
|
||||
const oldtoken = storage.oldToken;
|
||||
if (oldtoken) {
|
||||
await fetch('/api/auth/logout', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${oldtoken}`
|
||||
}
|
||||
});
|
||||
|
||||
storage.oldToken = '';
|
||||
}
|
||||
} catch (_e: unknown) {
|
||||
adze.error('Failed to revoke JWT');
|
||||
}
|
||||
storage.oldToken = '';
|
||||
}
|
||||
} catch (_e: unknown) {
|
||||
console.error('Failed to revoke JWT');
|
||||
}
|
||||
}
|
||||
|
||||
export function getJWTClaims(): JWTClaims | null {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1])) as JWTClaims;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1])) as JWTClaims;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getTokenHash(): Promise<string | null> {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await sha256(token);
|
||||
return await sha256(token);
|
||||
}
|
||||
|
||||
export async function isInitialized(): Promise<boolean[]> {
|
||||
try {
|
||||
const response = await axios.get('/api/health/basic', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${storage.token}`
|
||||
}
|
||||
});
|
||||
try {
|
||||
const response = await fetch('/api/health/basic', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${storage.token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 200 && response.data && response.data.data) {
|
||||
return [response.data.data.initialized === true, response.data.data.restarted === true];
|
||||
}
|
||||
} catch (_e: unknown) {
|
||||
return [false, false];
|
||||
}
|
||||
const responseData = await parseJSONResponse(response);
|
||||
|
||||
return [false, false];
|
||||
if (response.status === 200 && responseData && responseData.data) {
|
||||
return [responseData.data.initialized === true, responseData.data.restarted === true];
|
||||
}
|
||||
} catch (_e: unknown) {
|
||||
return [false, false];
|
||||
}
|
||||
|
||||
return [false, false];
|
||||
}
|
||||
|
||||
+286
-108
@@ -12,135 +12,313 @@ import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { storage } from '$lib';
|
||||
import type { APIResponse } from '$lib/types/common';
|
||||
import adze from 'adze';
|
||||
import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
export let ENDPOINT: string;
|
||||
export let API_ENDPOINT: string;
|
||||
|
||||
if (browser) {
|
||||
ENDPOINT = window.location.origin;
|
||||
API_ENDPOINT = `${window.location.origin}/api`;
|
||||
ENDPOINT = window.location.origin;
|
||||
API_ENDPOINT = `${window.location.origin}/api`;
|
||||
} else {
|
||||
ENDPOINT = '';
|
||||
API_ENDPOINT = '';
|
||||
ENDPOINT = '';
|
||||
API_ENDPOINT = '';
|
||||
}
|
||||
|
||||
export const api: AxiosInstance = axios.create({
|
||||
baseURL: API_ENDPOINT
|
||||
});
|
||||
export type APIRequestConfig = {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
data?: unknown;
|
||||
body?: BodyInit | null;
|
||||
signal?: AbortSignal;
|
||||
credentials?: RequestCredentials;
|
||||
validateStatus?: (status: number) => boolean;
|
||||
};
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
if (browser) {
|
||||
if (storage.token) {
|
||||
config.headers['Authorization'] = `Bearer ${storage.token}`;
|
||||
}
|
||||
export type APIClientResponse<T = unknown> = {
|
||||
status: number;
|
||||
data: T;
|
||||
headers: Record<string, string>;
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
if (storage.clusterToken) {
|
||||
config.headers['X-Cluster-Token'] = `Bearer ${storage.clusterToken}`;
|
||||
}
|
||||
type APIClientError = Error & {
|
||||
response?: APIClientResponse;
|
||||
request?: { url: string; method: string };
|
||||
status?: number;
|
||||
handled?: boolean;
|
||||
};
|
||||
|
||||
const routeHost = window.location.pathname.split('/').filter(Boolean)[0] || '';
|
||||
const pathBasedHost =
|
||||
routeHost !== '' && routeHost !== 'datacenter' && routeHost !== 'login' ? routeHost : '';
|
||||
const fallbackHost = pathBasedHost || storage.hostname || '';
|
||||
const explicitHost =
|
||||
typeof config.headers['X-Current-Hostname'] === 'string'
|
||||
? config.headers['X-Current-Hostname']
|
||||
: '';
|
||||
const defaultValidateStatus = (status: number): boolean => status >= 200 && status < 300;
|
||||
|
||||
if (explicitHost) {
|
||||
config.headers['X-Current-Hostname'] = explicitHost;
|
||||
} else if (
|
||||
(config.url === '/vm' || config.url === '/jail') &&
|
||||
config.method?.toLowerCase() === 'post'
|
||||
) {
|
||||
try {
|
||||
if (config.data?.node) {
|
||||
config.headers['X-Current-Hostname'] = `${config.data.node}`;
|
||||
} else if (fallbackHost) {
|
||||
config.headers['X-Current-Hostname'] = `${fallbackHost}`;
|
||||
}
|
||||
} catch (e) {
|
||||
adze.withEmoji.error('Error parsing request data:', e);
|
||||
if (fallbackHost) {
|
||||
config.headers['X-Current-Hostname'] = `${fallbackHost}`;
|
||||
}
|
||||
}
|
||||
} else if (fallbackHost) {
|
||||
config.headers['X-Current-Hostname'] = `${fallbackHost}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
function toHeaderRecord(headers: Headers): Record<string, string> {
|
||||
const record: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
record[key] = value;
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401 && browser) {
|
||||
toast.error('Session expired, please login again', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
handleAxiosError(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
function normalizeURL(url: string): string {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
return `${API_ENDPOINT}${url}`;
|
||||
}
|
||||
|
||||
return `${API_ENDPOINT}/${url}`;
|
||||
}
|
||||
|
||||
function applyRequestDefaults(config: APIRequestConfig): APIRequestConfig {
|
||||
const nextConfig: APIRequestConfig = {
|
||||
...config,
|
||||
headers: { ...(config.headers || {}) }
|
||||
};
|
||||
|
||||
if (!browser) {
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
if (storage.token) {
|
||||
nextConfig.headers!.Authorization = `Bearer ${storage.token}`;
|
||||
}
|
||||
|
||||
if (storage.clusterToken) {
|
||||
nextConfig.headers!['X-Cluster-Token'] = `Bearer ${storage.clusterToken}`;
|
||||
}
|
||||
|
||||
const routeHost = window.location.pathname.split('/').filter(Boolean)[0] || '';
|
||||
const pathBasedHost =
|
||||
routeHost !== '' && routeHost !== 'datacenter' && routeHost !== 'login' ? routeHost : '';
|
||||
const fallbackHost = pathBasedHost || storage.hostname || '';
|
||||
const explicitHost =
|
||||
typeof nextConfig.headers!['X-Current-Hostname'] === 'string'
|
||||
? nextConfig.headers!['X-Current-Hostname']
|
||||
: '';
|
||||
|
||||
if (explicitHost) {
|
||||
nextConfig.headers!['X-Current-Hostname'] = explicitHost;
|
||||
} else if (
|
||||
(nextConfig.url === '/vm' || nextConfig.url === '/jail') &&
|
||||
nextConfig.method?.toLowerCase() === 'post'
|
||||
) {
|
||||
try {
|
||||
const bodyData = nextConfig.data as { node?: string } | undefined;
|
||||
if (bodyData?.node) {
|
||||
nextConfig.headers!['X-Current-Hostname'] = `${bodyData.node}`;
|
||||
} else if (fallbackHost) {
|
||||
nextConfig.headers!['X-Current-Hostname'] = `${fallbackHost}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing request data:', e);
|
||||
if (fallbackHost) {
|
||||
nextConfig.headers!['X-Current-Hostname'] = `${fallbackHost}`;
|
||||
}
|
||||
}
|
||||
} else if (fallbackHost) {
|
||||
nextConfig.headers!['X-Current-Hostname'] = `${fallbackHost}`;
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
function isAPIClientError(error: unknown): error is APIClientError {
|
||||
return typeof error === 'object' && error !== null && 'request' in error;
|
||||
}
|
||||
|
||||
function isBodyInit(value: unknown): value is BodyInit {
|
||||
return (
|
||||
typeof value === 'string' ||
|
||||
value instanceof Blob ||
|
||||
value instanceof FormData ||
|
||||
value instanceof URLSearchParams ||
|
||||
value instanceof ArrayBuffer ||
|
||||
ArrayBuffer.isView(value) ||
|
||||
value instanceof ReadableStream
|
||||
);
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
if (response.status === 204 || response.status === 205) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json') || contentType.includes('+json')) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_e: unknown) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.text();
|
||||
} catch (_e: unknown) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class FetchAPIClient {
|
||||
async request<T = unknown>(config: APIRequestConfig): Promise<APIClientResponse<T>> {
|
||||
const nextConfig = applyRequestDefaults(config);
|
||||
const method = (nextConfig.method || 'GET').toUpperCase();
|
||||
const url = normalizeURL(nextConfig.url);
|
||||
const headers = { ...(nextConfig.headers || {}) };
|
||||
|
||||
let body: BodyInit | null = nextConfig.body ?? null;
|
||||
if (body === null && nextConfig.data !== undefined) {
|
||||
if (isBodyInit(nextConfig.data)) {
|
||||
body = nextConfig.data;
|
||||
} else {
|
||||
if (!headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
body = JSON.stringify(nextConfig.data);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: nextConfig.signal,
|
||||
credentials: nextConfig.credentials || 'same-origin'
|
||||
});
|
||||
|
||||
const data = (await parseResponseBody(response)) as T;
|
||||
const normalizedResponse: APIClientResponse<T> = {
|
||||
status: response.status,
|
||||
data,
|
||||
headers: toHeaderRecord(response.headers),
|
||||
ok: response.ok
|
||||
};
|
||||
|
||||
const validateStatus = nextConfig.validateStatus || defaultValidateStatus;
|
||||
if (!validateStatus(response.status)) {
|
||||
const error: APIClientError = new Error(
|
||||
`Request failed with status code ${response.status}`
|
||||
);
|
||||
error.response = normalizedResponse;
|
||||
error.request = { url: nextConfig.url, method };
|
||||
error.status = response.status;
|
||||
|
||||
if (response.status === 401 && browser) {
|
||||
toast.error('Session expired, please login again', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
handleAxiosError(error);
|
||||
error.handled = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return normalizedResponse;
|
||||
} catch (error: unknown) {
|
||||
if (isAPIClientError(error) && error.handled) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedError: APIClientError =
|
||||
isAPIClientError(error) && error.request
|
||||
? error
|
||||
: Object.assign(new Error('Network error'), {
|
||||
request: { url: nextConfig.url, method }
|
||||
});
|
||||
|
||||
handleAxiosError(normalizedError);
|
||||
throw normalizedError;
|
||||
}
|
||||
}
|
||||
|
||||
get<T = unknown>(url: string, config: Omit<APIRequestConfig, 'url' | 'method'> = {}) {
|
||||
return this.request<T>({ ...config, url, method: 'GET' });
|
||||
}
|
||||
|
||||
post<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config: Omit<APIRequestConfig, 'url' | 'method' | 'data'> = {}
|
||||
) {
|
||||
return this.request<T>({ ...config, url, method: 'POST', data });
|
||||
}
|
||||
|
||||
put<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config: Omit<APIRequestConfig, 'url' | 'method' | 'data'> = {}
|
||||
) {
|
||||
return this.request<T>({ ...config, url, method: 'PUT', data });
|
||||
}
|
||||
|
||||
patch<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config: Omit<APIRequestConfig, 'url' | 'method' | 'data'> = {}
|
||||
) {
|
||||
return this.request<T>({ ...config, url, method: 'PATCH', data });
|
||||
}
|
||||
|
||||
delete<T = unknown>(url: string, config: Omit<APIRequestConfig, 'url' | 'method'> = {}) {
|
||||
return this.request<T>({ ...config, url, method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new FetchAPIClient();
|
||||
|
||||
export function handleAxiosError(error: unknown): void {
|
||||
if (!browser) return;
|
||||
if (!browser) return;
|
||||
|
||||
if (!axios.isAxiosError(error)) {
|
||||
toast.error('An unexpected error occurred', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
adze.withEmoji.error('An unexpected error occurred');
|
||||
return;
|
||||
}
|
||||
if (!isAPIClientError(error)) {
|
||||
toast.error('An unexpected error occurred', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
console.error('An unexpected error occurred');
|
||||
return;
|
||||
}
|
||||
|
||||
const axiosError = error as AxiosError<{ message?: string }>;
|
||||
if (axiosError.response) {
|
||||
const errorMessage =
|
||||
axiosError.response.data?.message || axiosError.message || 'An error occurred';
|
||||
adze.withEmoji.error(
|
||||
JSON.stringify({
|
||||
status: axiosError.response.status,
|
||||
data: axiosError.response.data,
|
||||
message: errorMessage
|
||||
})
|
||||
);
|
||||
} else if (axiosError.request) {
|
||||
adze.withEmoji.error('No response:', axiosError.request);
|
||||
}
|
||||
const axiosError = error as APIClientError;
|
||||
if (axiosError.response) {
|
||||
const responseData = axiosError.response.data as { message?: string } | undefined;
|
||||
const errorMessage =
|
||||
responseData?.message || axiosError.message || 'An error occurred';
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
status: axiosError.response.status,
|
||||
data: axiosError.response.data,
|
||||
message: errorMessage
|
||||
})
|
||||
);
|
||||
} else if (axiosError.request) {
|
||||
console.error('No response:', axiosError.request);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAPIResponse(
|
||||
response: APIResponse,
|
||||
messages: {
|
||||
success?: string;
|
||||
error?: string;
|
||||
info?: string;
|
||||
warn?: string;
|
||||
}
|
||||
response: APIResponse,
|
||||
messages: {
|
||||
success?: string;
|
||||
error?: string;
|
||||
info?: string;
|
||||
warn?: string;
|
||||
}
|
||||
): void {
|
||||
if (response.status === 'error') {
|
||||
adze.withEmoji.error(response);
|
||||
toast.error(messages.error || 'Operation failed', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
if (response.status === 'error') {
|
||||
console.error(response);
|
||||
toast.error(messages.error || 'Operation failed', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 'success') {
|
||||
toast.success(messages.success || 'Operation successful', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
if (response.status === 'success') {
|
||||
toast.success(messages.success || 'Operation successful', {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,19 @@
|
||||
|
||||
import { storage } from '$lib';
|
||||
import { reload } from '$lib/stores/api.svelte';
|
||||
import axios from 'axios';
|
||||
|
||||
async function parseJSONResponse(response: Response): Promise<any> {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json') && !contentType.includes('+json')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_e: unknown) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type SSETokenResponse = {
|
||||
token: string;
|
||||
@@ -55,14 +67,16 @@ async function fetchSSEToken(): Promise<string | null> {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/auth/sse-token', {
|
||||
const response = await fetch('/api/auth/sse-token', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${storage.token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status < 400 && response.data?.data) {
|
||||
const data = response.data.data as SSETokenResponse;
|
||||
const responseData = await parseJSONResponse(response);
|
||||
|
||||
if (response.status < 400 && responseData?.data) {
|
||||
const data = responseData.data as SSETokenResponse;
|
||||
if (data.token) {
|
||||
return data.token;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { Arc, ArcChart, Group, LinearGradient, Text } from 'layerchart';
|
||||
import { Chart } from 'svelte-echarts';
|
||||
import { init, use } from 'echarts/core';
|
||||
import { GaugeChart } from 'echarts/charts';
|
||||
import { GraphicComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { mode } from 'mode-watcher';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
|
||||
use([GaugeChart, GraphicComponent, CanvasRenderer]);
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -9,62 +17,93 @@
|
||||
|
||||
let { title, subtitle, value }: Props = $props();
|
||||
|
||||
const ranges = [
|
||||
{ max: 33, color: 'fill-green-500' },
|
||||
{ max: 66, color: 'fill-yellow-500' },
|
||||
{ max: 100, color: 'fill-red-500' }
|
||||
] as const;
|
||||
const getColor = (v: number) => {
|
||||
if (v <= 33) return '#22c55e';
|
||||
if (v <= 66) return '#eab308';
|
||||
return '#ef4444';
|
||||
};
|
||||
|
||||
const color = $derived(ranges.find((r) => value <= r.max)?.color ?? 'fill-red-500');
|
||||
let options: EChartsOption = $derived({
|
||||
graphic: {
|
||||
elements: [
|
||||
{
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: 40,
|
||||
style: {
|
||||
text: title,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
fill: mode.current === 'dark' ? '#ffffff' : '#000000'
|
||||
}
|
||||
},
|
||||
...(subtitle
|
||||
? [
|
||||
{
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: 65,
|
||||
style: {
|
||||
text: subtitle,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
fill: mode.current === 'dark' ? '#a1a1aa' : '#71717a'
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: 85,
|
||||
style: {
|
||||
text: `${Math.round(value)}%`,
|
||||
fontSize: 30,
|
||||
fontWeight: 'bold',
|
||||
fill: mode.current === 'dark' ? '#ffffff' : '#000000'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'gauge',
|
||||
startAngle: 210,
|
||||
endAngle: -30,
|
||||
min: 0,
|
||||
max: 100,
|
||||
center: ['50%', '50%'],
|
||||
radius: '100%',
|
||||
pointer: { show: false },
|
||||
progress: {
|
||||
show: true,
|
||||
overlap: false,
|
||||
roundCap: true,
|
||||
clip: false,
|
||||
itemStyle: {
|
||||
color: getColor(value),
|
||||
opacity: 0.7
|
||||
}
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
width: 15,
|
||||
color: [
|
||||
[1, mode.current === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)']
|
||||
]
|
||||
}
|
||||
},
|
||||
splitLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false },
|
||||
detail: { show: false },
|
||||
title: { show: false },
|
||||
data: [{ value: value }]
|
||||
}
|
||||
]
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-[150px] w-[200px] overflow-auto rounded-sm">
|
||||
<ArcChart>
|
||||
{#snippet marks()}
|
||||
<Group y={20}>
|
||||
<Arc
|
||||
{value}
|
||||
domain={[0, 100]}
|
||||
outerRadius={80}
|
||||
innerRadius={-15}
|
||||
cornerRadius={20}
|
||||
padAngle={0.02}
|
||||
range={[-120, 120]}
|
||||
class={`${color} opacity-70`}
|
||||
track={{ class: 'fill-none stroke-primary/20' }}
|
||||
>
|
||||
{#snippet children({ value })}
|
||||
<Text
|
||||
value={title}
|
||||
textAnchor="middle"
|
||||
verticalAnchor="end"
|
||||
y={-35}
|
||||
class="text-md font-bold"
|
||||
fill="currentColor"
|
||||
/>
|
||||
|
||||
{#if subtitle}
|
||||
<Text
|
||||
value={subtitle}
|
||||
textAnchor="middle"
|
||||
verticalAnchor="end"
|
||||
y={-10}
|
||||
class="text-xs font-medium"
|
||||
fill="currentColor"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Text
|
||||
value={Math.round(value) + '%'}
|
||||
textAnchor="middle"
|
||||
verticalAnchor="start"
|
||||
y={10}
|
||||
class="text-3xl font-bold"
|
||||
fill="currentColor"
|
||||
/>
|
||||
{/snippet}
|
||||
</Arc>
|
||||
</Group>
|
||||
{/snippet}
|
||||
</ArcChart>
|
||||
<div class="h-37.5 w-50 overflow-hidden rounded-sm">
|
||||
<Chart {init} {options} class="h-full w-full" />
|
||||
</div>
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import type { AreaChartElement } from '$lib/types/components/chart';
|
||||
import { switchColor } from '$lib/utils/chart';
|
||||
import {
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip
|
||||
} from 'chart.js';
|
||||
import zoomPlugin from 'chartjs-plugin-zoom';
|
||||
import { format } from 'date-fns';
|
||||
import humanFormat from 'human-format';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
elements: AreaChartElement[];
|
||||
formatSize?: boolean;
|
||||
containerClass?: string;
|
||||
showResetButton?: boolean;
|
||||
chart: Chart | null;
|
||||
percentage?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
title = '',
|
||||
description = '',
|
||||
icon = '',
|
||||
elements,
|
||||
formatSize = false,
|
||||
containerClass = 'p-5',
|
||||
showResetButton = true,
|
||||
chart = $bindable(),
|
||||
percentage = false
|
||||
}: Props = $props();
|
||||
|
||||
let data = $derived.by(() => {
|
||||
if (!elements?.length) return [];
|
||||
|
||||
const THRESH = 60_000;
|
||||
const series = elements.map(({ field, data: pts }) => ({
|
||||
field,
|
||||
points: pts
|
||||
.map((p) => ({ t: new Date(p.date).getTime(), v: p.value }))
|
||||
.sort((a, b) => a.t - b.t)
|
||||
}));
|
||||
|
||||
series.sort((a, b) => a.points.length - b.points.length);
|
||||
const [base, ...others] = series;
|
||||
|
||||
const out = [];
|
||||
|
||||
for (const { t: bt, v: bv } of base.points) {
|
||||
const rec = { date: new Date(bt), [base.field]: bv };
|
||||
let good = true;
|
||||
|
||||
for (const { field, points } of others) {
|
||||
let bestDiff = Infinity,
|
||||
bestVal = null;
|
||||
for (const { t, v } of points) {
|
||||
const d = Math.abs(t - bt);
|
||||
if (d < bestDiff) {
|
||||
bestDiff = d;
|
||||
bestVal = v;
|
||||
}
|
||||
|
||||
if (t - bt > bestDiff) break;
|
||||
}
|
||||
if (bestVal === null || bestDiff > THRESH) {
|
||||
good = false;
|
||||
break;
|
||||
}
|
||||
rec[field] = bestVal;
|
||||
}
|
||||
|
||||
if (good) out.push(rec);
|
||||
}
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
let series = $derived.by(() => {
|
||||
if (!elements?.length) return [];
|
||||
return elements.map((element) => ({
|
||||
key: element.field,
|
||||
label: element.label,
|
||||
color: element.color
|
||||
}));
|
||||
});
|
||||
|
||||
const labels = $derived.by(() => {
|
||||
return data.map((v) => [
|
||||
format(new Date(v.date), 'dd/MM/yyyy'),
|
||||
format(new Date(v.date), 'HH:mm')
|
||||
]);
|
||||
});
|
||||
|
||||
let datasets = $derived.by(() => {
|
||||
return series.map((s, i) => ({
|
||||
label: s.label,
|
||||
data: data.map((d) => Number(d[s.key])),
|
||||
borderColor: switchColor(s.color),
|
||||
backgroundColor: switchColor(s.color, 0.2),
|
||||
fill: i === 0 ? 'origin' : '-1',
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
order: s.label === 'CPU Usage' ? 2 : 1
|
||||
}));
|
||||
});
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let zoomEnabled = $state(false);
|
||||
|
||||
Chart.register(
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
zoomPlugin
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
transitions: {
|
||||
zoom: {
|
||||
animation: {
|
||||
duration: 1000,
|
||||
easing: 'easeOutCubic'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: (tooltipItems) => {
|
||||
try {
|
||||
const dataIndex = tooltipItems[0].dataIndex;
|
||||
const date = data[dataIndex]?.date;
|
||||
if (!date) return 'Invalid Date';
|
||||
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(dateObj.getTime())) return 'Invalid Date';
|
||||
|
||||
return [format(dateObj, 'dd/MM/yyyy'), format(dateObj, 'HH:mm:ss')];
|
||||
} catch (e) {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
},
|
||||
label: (tooltipItem) => {
|
||||
const datasetLabel = tooltipItem.dataset.label || '';
|
||||
const value = Number(tooltipItem.raw);
|
||||
|
||||
return `${datasetLabel}: ${
|
||||
formatSize ? humanFormat(value) : value.toLocaleString()
|
||||
}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
zoom: {
|
||||
wheel: { enabled: zoomEnabled },
|
||||
pinch: { enabled: zoomEnabled },
|
||||
mode: 'xy'
|
||||
},
|
||||
pan: {
|
||||
enabled: zoomEnabled,
|
||||
mode: 'xy'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: { color: '#ccc', display: true, text: 'Date' },
|
||||
ticks: {
|
||||
callback: function (value, index, ticks) {
|
||||
try {
|
||||
const date = data[index]?.date;
|
||||
if (!date) return '';
|
||||
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(dateObj.getTime())) return '';
|
||||
|
||||
return [format(date, 'dd/MM/yyyy'), format(date, 'HH:mm')];
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: '#333' // Optional: X-axis grid line color
|
||||
}
|
||||
},
|
||||
y: {
|
||||
min: percentage ? 0 : undefined,
|
||||
max: percentage ? 100 : undefined,
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
color: '#ccc',
|
||||
display: true,
|
||||
text: 'Value'
|
||||
},
|
||||
ticks: {
|
||||
callback: function (value) {
|
||||
const numValue = Number(value);
|
||||
return formatSize ? humanFormat(numValue) : numValue.toLocaleString();
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: '#333' // Optional: X-axis grid line color
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
setTimeout(() => chart?.resize(), 300);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (chart) {
|
||||
if (
|
||||
chart.options &&
|
||||
chart.options.plugins &&
|
||||
chart.options.plugins.zoom &&
|
||||
chart.options.plugins.zoom.zoom &&
|
||||
chart.options.plugins.zoom.zoom.wheel
|
||||
) {
|
||||
chart.options.plugins.zoom.zoom.wheel.enabled = zoomEnabled;
|
||||
}
|
||||
if (
|
||||
chart.options &&
|
||||
chart.options.plugins &&
|
||||
chart.options.plugins.zoom &&
|
||||
chart.options.plugins.zoom.zoom &&
|
||||
chart.options.plugins.zoom.zoom.pinch
|
||||
) {
|
||||
chart.options.plugins.zoom.zoom.pinch.enabled = zoomEnabled;
|
||||
}
|
||||
if (
|
||||
chart.options &&
|
||||
chart.options.plugins &&
|
||||
chart.options.plugins.zoom &&
|
||||
chart.options.plugins.zoom.pan
|
||||
) {
|
||||
chart.options.plugins.zoom.pan.enabled = zoomEnabled;
|
||||
}
|
||||
chart.update('none');
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
chart = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root class={containerClass}>
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if icon}
|
||||
<span class={icon}></span>
|
||||
{/if}
|
||||
{title}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
onclick={() => {
|
||||
zoomEnabled = !zoomEnabled;
|
||||
}}
|
||||
variant={zoomEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
class="h-8"
|
||||
>
|
||||
<span class="icon-[material-symbols--zoom-in] h-4 w-4"></span>
|
||||
{zoomEnabled ? 'Disable Zoom' : 'Enable Zoom'}
|
||||
</Button>
|
||||
{#if showResetButton && zoomEnabled}
|
||||
<Button
|
||||
onclick={() => {
|
||||
chart?.resetZoom();
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
>
|
||||
<span class="icon-[carbon--reset] h-4 w-4"></span>
|
||||
Reset zoom
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Title>
|
||||
{#if description}
|
||||
<Card.Description>{description}</Card.Description>
|
||||
{/if}
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="h-full min-h-[300px] w-full p-0">
|
||||
<canvas bind:this={canvas} style="width: 100%; height: 100%; display: block;"></canvas>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -1,196 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import type { SeriesDataWithBaseline } from '$lib/types/common';
|
||||
import { switchColor } from '$lib/utils/chart';
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Legend,
|
||||
LinearScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
type ChartConfiguration
|
||||
} from 'chart.js';
|
||||
import zoomPlugin from 'chartjs-plugin-zoom';
|
||||
import { format } from 'date-fns';
|
||||
import humanFormat from 'human-format';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
Chart.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarController,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
zoomPlugin
|
||||
);
|
||||
|
||||
type Colors = {
|
||||
baseline: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
data: SeriesDataWithBaseline[];
|
||||
colors: Colors;
|
||||
formatter?: 'size-formatter' | 'default';
|
||||
icon?: string;
|
||||
title?: string;
|
||||
showResetButton?: boolean;
|
||||
chart?: Chart;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
icon,
|
||||
data,
|
||||
colors,
|
||||
formatter = 'default',
|
||||
showResetButton = true,
|
||||
chart = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
|
||||
const chartConfig: ChartConfiguration<'bar'> = {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.map((d) => d.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Baseline',
|
||||
data: data.map((d) => d.baseline),
|
||||
backgroundColor: switchColor(colors.baseline, 0.6),
|
||||
borderColor: switchColor(colors.baseline, 1),
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: 'Value',
|
||||
data: data.map((d) => d.value),
|
||||
backgroundColor: switchColor(colors.value, 0.6),
|
||||
borderColor: switchColor(colors.value, 1),
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const label = context.label || '';
|
||||
const value = context.raw as number;
|
||||
const displayValue = formatter === 'size-formatter' ? humanFormat(value) : value;
|
||||
return `${label}: ${displayValue}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
pan: { enabled: true, mode: 'xy' },
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
mode: 'xy'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: { color: '#ccc', display: true, text: 'Date' },
|
||||
|
||||
grid: {
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
color: '#ccc',
|
||||
display: true,
|
||||
text: 'Value'
|
||||
},
|
||||
ticks: {
|
||||
callback: function (value) {
|
||||
const numValue = Number(value);
|
||||
return formatter == 'size-formatter'
|
||||
? humanFormat(numValue)
|
||||
: numValue.toLocaleString();
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: '#333'
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (canvas) {
|
||||
chart = new Chart(canvas, chartConfig);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (chart && data && data.length > 0) {
|
||||
chart.data.labels = data.map((d) => d.name);
|
||||
|
||||
chart.data.datasets[0].data = data.map((d) => d.baseline);
|
||||
chart.data.datasets[0].backgroundColor = switchColor(colors.baseline, 0.6);
|
||||
chart.data.datasets[0].borderColor = switchColor(colors.baseline, 1);
|
||||
|
||||
chart.data.datasets[1].data = data.map((d) => d.value);
|
||||
chart.data.datasets[1].backgroundColor = switchColor(colors.value, 0.6);
|
||||
chart.data.datasets[1].borderColor = switchColor(colors.value, 1);
|
||||
|
||||
chart.update();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
chart?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative min-h-[300px] w-full">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if icon}
|
||||
<span class="icon-[{icon}] h-5 w-5"></span>
|
||||
{/if}
|
||||
{title}
|
||||
</div>
|
||||
{#if showResetButton}
|
||||
<div>
|
||||
<Button
|
||||
onclick={() => {
|
||||
chart?.resetZoom();
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
>
|
||||
<span class="icon-[carbon--reset] h-4 w-4"></span>
|
||||
Reset zoom
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="h-full min-h-[300px] w-full">
|
||||
<canvas bind:this={canvas} class="h-full w-full"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,80 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PieChartData } from '$lib/types/common';
|
||||
import { switchColor } from '$lib/utils/chart';
|
||||
import { ArcElement, Chart, Legend, PieController, Tooltip } from 'chart.js';
|
||||
import humanFormat from 'human-format';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
containerClass: string;
|
||||
data: PieChartData[];
|
||||
formatter?: 'size-formatter' | 'default';
|
||||
}
|
||||
|
||||
const { containerClass, data: rawData, formatter = 'default' }: Props = $props();
|
||||
|
||||
let data = $derived.by(() => {
|
||||
if (!rawData || rawData.length === 0) return [];
|
||||
return rawData.map((item, index) => ({
|
||||
...item,
|
||||
color: item.color || `chart-${(index % 5) + 1}`
|
||||
}));
|
||||
});
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart;
|
||||
|
||||
Chart.register(ArcElement, PieController, Tooltip, Legend);
|
||||
|
||||
onMount(() => {
|
||||
chart = new Chart(canvas, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: data.map((d) => d.label),
|
||||
datasets: [
|
||||
{
|
||||
data: data.map((d) => d.value),
|
||||
backgroundColor: data.map((d) => switchColor(d.color, 0.6)),
|
||||
borderColor: data.map((d) => switchColor(d.color, 1)),
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const label = context.label || '';
|
||||
const value = context.raw as number;
|
||||
const displayValue = formatter === 'size-formatter' ? humanFormat(value) : value;
|
||||
return `${label}: ${displayValue}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (chart && data && data.length > 0) {
|
||||
chart.data.labels = data.map((d) => d.label);
|
||||
chart.data.datasets[0].data = data.map((d) => d.value);
|
||||
chart.data.datasets[0].backgroundColor = data.map((d) => switchColor(d.color));
|
||||
chart.update();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
chart?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={containerClass}>
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
@@ -1,80 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import ChartStyle from './chart-style.svelte';
|
||||
import { setChartContext, type ChartConfig } from './chart-utils.js';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = uid,
|
||||
class: className,
|
||||
children,
|
||||
config,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
|
||||
config: ChartConfig;
|
||||
} = $props();
|
||||
|
||||
const chartId = `chart-${id || uid.replace(/:/g, '')}`;
|
||||
|
||||
setChartContext({
|
||||
get config() {
|
||||
return config;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-chart={chartId}
|
||||
data-slot="chart"
|
||||
class={cn(
|
||||
'flex aspect-video justify-center overflow-visible text-xs',
|
||||
// Overrides
|
||||
//
|
||||
// Stroke around dots/marks when hovering
|
||||
'[&_.stroke-white]:stroke-transparent',
|
||||
// override the default stroke color of lines
|
||||
'[&_.lc-line]:stroke-border/50',
|
||||
|
||||
// by default, layerchart shows a line intersecting the point when hovering, this hides that
|
||||
'[&_.lc-highlight-line]:stroke-0',
|
||||
|
||||
// by default, when you hover a point on a stacked series chart, it will drop the opacity
|
||||
// of the other series, this overrides that
|
||||
'[&_.lc-area-path]:opacity-100 [&_.lc-highlight-line]:opacity-100 [&_.lc-highlight-point]:opacity-100 [&_.lc-spline-path]:opacity-100 [&_.lc-text-svg]:overflow-visible [&_.lc-text]:text-xs',
|
||||
|
||||
// We don't want the little tick lines between the axis labels and the chart, so we remove
|
||||
// the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every
|
||||
// chart.
|
||||
'[&_.lc-axis-tick]:stroke-0',
|
||||
|
||||
// We don't want to display the rule on the x/y axis, as there is already going to be
|
||||
// a grid line there and rule ends up overlapping the marks because it is rendered after
|
||||
// the marks
|
||||
'[&_.lc-rule-x-line:not(.lc-grid-x-rule)]:stroke-0 [&_.lc-rule-y-line:not(.lc-grid-y-rule)]:stroke-0',
|
||||
'[&_.lc-grid-x-radial-line]:stroke-border [&_.lc-grid-x-radial-circle]:stroke-border',
|
||||
'[&_.lc-grid-y-radial-line]:stroke-border [&_.lc-grid-y-radial-circle]:stroke-border',
|
||||
|
||||
// Legend adjustments
|
||||
'[&_.lc-legend-swatch-button]:items-center [&_.lc-legend-swatch-button]:gap-1.5',
|
||||
'[&_.lc-legend-swatch-group]:items-center [&_.lc-legend-swatch-group]:gap-4',
|
||||
'[&_.lc-legend-swatch]:size-2.5 [&_.lc-legend-swatch]:rounded-[2px]',
|
||||
|
||||
// Labels
|
||||
'[&_.lc-labels-text:not([fill])]:fill-foreground [&_text]:stroke-transparent',
|
||||
|
||||
// Tick labels on th x/y axes
|
||||
'[&_.lc-axis-tick-label]:fill-muted-foreground [&_.lc-axis-tick-label]:font-normal',
|
||||
'[&_.lc-tooltip-rects-g]:fill-transparent',
|
||||
'[&_.lc-layout-svg-g]:fill-transparent',
|
||||
'[&_.lc-root-container]:w-full',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChartStyle id={chartId} {config} />
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { THEMES, type ChartConfig } from './chart-utils.js';
|
||||
|
||||
let { id, config }: { id: string; config: ChartConfig } = $props();
|
||||
|
||||
const colorConfig = $derived(
|
||||
config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null
|
||||
);
|
||||
|
||||
const themeContents = $derived.by(() => {
|
||||
if (!colorConfig || !colorConfig.length) return;
|
||||
|
||||
const themeContents = [];
|
||||
for (let [_theme, prefix] of Object.entries(THEMES)) {
|
||||
let content = `${prefix} [data-chart=${id}] {\n`;
|
||||
const color = colorConfig.map(([key, itemConfig]) => {
|
||||
const theme = _theme as keyof typeof itemConfig.theme;
|
||||
const color = itemConfig.theme?.[theme] || itemConfig.color;
|
||||
return color ? `\t--color-${key}: ${color};` : null;
|
||||
});
|
||||
|
||||
content += color.join('\n') + '\n}';
|
||||
|
||||
themeContents.push(content);
|
||||
}
|
||||
|
||||
return themeContents.join('\n');
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if themeContents}
|
||||
{#key id}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html `<style>${themeContents}</style>`}
|
||||
{/key}
|
||||
{/if}
|
||||
@@ -1,155 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
|
||||
import { getTooltipContext, Tooltip as TooltipPrimitive } from 'layerchart';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { getPayloadConfigFromPayload, useChart, type TooltipPayload } from './chart-utils.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function defaultFormatter(value: any, _payload: TooltipPayload[]) {
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
hideLabel = false,
|
||||
indicator = 'dot',
|
||||
hideIndicator = false,
|
||||
labelKey,
|
||||
label,
|
||||
labelFormatter = defaultFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
nameKey,
|
||||
color,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> & {
|
||||
hideLabel?: boolean;
|
||||
label?: string;
|
||||
indicator?: 'line' | 'dot' | 'dashed';
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
hideIndicator?: boolean;
|
||||
labelClassName?: string;
|
||||
labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null;
|
||||
formatter?: Snippet<
|
||||
[
|
||||
{
|
||||
value: unknown;
|
||||
name: string;
|
||||
item: TooltipPayload;
|
||||
index: number;
|
||||
payload: TooltipPayload[];
|
||||
}
|
||||
]
|
||||
>;
|
||||
} = $props();
|
||||
|
||||
const chart = useChart();
|
||||
const tooltipCtx = getTooltipContext();
|
||||
|
||||
const formattedLabel = $derived.by(() => {
|
||||
if (hideLabel || !tooltipCtx.payload?.length) return null;
|
||||
|
||||
const [item] = tooltipCtx.payload;
|
||||
const key = labelKey || item?.label || item?.name || 'value';
|
||||
|
||||
const itemConfig = getPayloadConfigFromPayload(chart.config, item, key);
|
||||
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? chart.config[label as keyof typeof chart.config]?.label || label
|
||||
: (itemConfig?.label ?? item.label);
|
||||
|
||||
if (!value) return null;
|
||||
if (!labelFormatter) return value;
|
||||
return labelFormatter(value, tooltipCtx.payload);
|
||||
});
|
||||
|
||||
const nestLabel = $derived(tooltipCtx.payload.length === 1 && indicator !== 'dot');
|
||||
</script>
|
||||
|
||||
{#snippet TooltipLabel()}
|
||||
{#if formattedLabel}
|
||||
<div class={cn('font-medium', labelClassName)}>
|
||||
{#if typeof formattedLabel === 'function'}
|
||||
{@render formattedLabel()}
|
||||
{:else}
|
||||
{formattedLabel}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<TooltipPrimitive.Root variant="none">
|
||||
<div
|
||||
class={cn(
|
||||
'border-border/50 bg-background grid min-w-[9rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#if !nestLabel}
|
||||
{@render TooltipLabel()}
|
||||
{/if}
|
||||
<div class="grid gap-1.5">
|
||||
{#each tooltipCtx.payload as item, i (item.key + i)}
|
||||
{@const key = `${nameKey || item.key || item.name || 'value'}`}
|
||||
{@const itemConfig = getPayloadConfigFromPayload(chart.config, item, key)}
|
||||
{@const indicatorColor = color || item.payload?.color || item.color}
|
||||
<div
|
||||
class={cn(
|
||||
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5',
|
||||
indicator === 'dot' && 'items-center'
|
||||
)}
|
||||
>
|
||||
{#if formatter && item.value !== undefined && item.name}
|
||||
{@render formatter({
|
||||
value: item.value,
|
||||
name: item.name,
|
||||
item,
|
||||
index: i,
|
||||
payload: tooltipCtx.payload
|
||||
})}
|
||||
{:else}
|
||||
{#if itemConfig?.icon}
|
||||
<itemConfig.icon />
|
||||
{:else if !hideIndicator}
|
||||
<div
|
||||
style="--color-bg: {indicatorColor}; --color-border: {indicatorColor};"
|
||||
class={cn('border-(--color-border) bg-(--color-bg) shrink-0 rounded-[2px]', {
|
||||
'size-2.5': indicator === 'dot',
|
||||
'h-full w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed'
|
||||
})}
|
||||
></div>
|
||||
{/if}
|
||||
<div
|
||||
class={cn(
|
||||
'flex flex-1 shrink-0 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
<div class="grid gap-1.5">
|
||||
{#if nestLabel}
|
||||
{@render TooltipLabel()}
|
||||
{/if}
|
||||
<span class="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{#if item.value}
|
||||
<span class="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipPrimitive.Root>
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { Tooltip } from 'layerchart';
|
||||
import { getContext, setContext, type Component, type ComponentProps, type Snippet } from 'svelte';
|
||||
|
||||
export const THEMES = { light: '', dark: '.dark' } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: string;
|
||||
icon?: Component;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never;
|
||||
|
||||
export type TooltipPayload = ExtractSnippetParams<
|
||||
ComponentProps<typeof Tooltip.Root>['children']
|
||||
>['payload'][number];
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
export function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: TooltipPayload,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== 'object' || payload === null) return undefined;
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (payload.key === key) {
|
||||
configLabelKey = payload.key;
|
||||
} else if (payload.name === key) {
|
||||
configLabelKey = payload.name;
|
||||
} else if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
type ChartContextValue = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const chartContextKey = Symbol('chart-context');
|
||||
|
||||
export function setChartContext(value: ChartContextValue) {
|
||||
return setContext(chartContextKey, value);
|
||||
}
|
||||
|
||||
export function useChart() {
|
||||
return getContext<ChartContextValue>(chartContextKey);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import ChartContainer from './chart-container.svelte';
|
||||
import ChartTooltip from './chart-tooltip.svelte';
|
||||
|
||||
export { getPayloadConfigFromPayload, type ChartConfig } from './chart-utils.js';
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartContainer as Container, ChartTooltip as Tooltip };
|
||||
+120
-121
@@ -12,169 +12,168 @@ import { storage } from '$lib';
|
||||
import { api } from '$lib/api/common';
|
||||
import { reload } from '$lib/stores/api.svelte';
|
||||
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
|
||||
import adze from 'adze';
|
||||
import { z } from 'zod/v4';
|
||||
import { kvStorage } from '$lib/types/db';
|
||||
|
||||
export type APIRequestOptions = {
|
||||
raw?: boolean;
|
||||
hostname?: string;
|
||||
headers?: Record<string, string>;
|
||||
raw?: boolean;
|
||||
hostname?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export async function apiRequest<T extends z.ZodType>(
|
||||
endpoint: string,
|
||||
schema: T,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
|
||||
body?: unknown,
|
||||
options?: APIRequestOptions
|
||||
endpoint: string,
|
||||
schema: T,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
|
||||
body?: unknown,
|
||||
options?: APIRequestOptions
|
||||
): Promise<z.infer<T>> {
|
||||
function setReloadFlag() {
|
||||
if (method !== 'GET') {
|
||||
reload.auditLog = true;
|
||||
}
|
||||
}
|
||||
function setReloadFlag() {
|
||||
if (method !== 'GET') {
|
||||
reload.auditLog = true;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const config = {
|
||||
method,
|
||||
url: endpoint,
|
||||
headers: {
|
||||
...(options?.headers || {}),
|
||||
...(options?.hostname ? { 'X-Current-Hostname': options.hostname } : {})
|
||||
},
|
||||
...(body ? { data: body } : {})
|
||||
};
|
||||
try {
|
||||
const config = {
|
||||
method,
|
||||
url: endpoint,
|
||||
headers: {
|
||||
...(options?.headers || {}),
|
||||
...(options?.hostname ? { 'X-Current-Hostname': options.hostname } : {})
|
||||
},
|
||||
...(body ? { data: body } : {})
|
||||
};
|
||||
|
||||
const response = await api.request({ ...config, validateStatus: () => true });
|
||||
const apiResponse = APIResponseSchema.safeParse(response.data);
|
||||
const response = await api.request({ ...config, validateStatus: () => true });
|
||||
const apiResponse = APIResponseSchema.safeParse(response.data);
|
||||
|
||||
if (apiResponse.data) {
|
||||
if (apiResponse.data.status && apiResponse.data.status === 'error') {
|
||||
if (apiResponse.data.error && apiResponse.data.error === 'invalid_cluster_token') {
|
||||
storage.clusterToken = '';
|
||||
return apiRequest(endpoint, schema, method, body, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (apiResponse.data) {
|
||||
if (apiResponse.data.status && apiResponse.data.status === 'error') {
|
||||
if (apiResponse.data.error && apiResponse.data.error === 'invalid_cluster_token') {
|
||||
storage.clusterToken = '';
|
||||
return apiRequest(endpoint, schema, method, body, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Couldn't parse response data into APIResponse so we'll just return the data? */
|
||||
if (!apiResponse.success) {
|
||||
setReloadFlag();
|
||||
if (apiResponse.data) {
|
||||
return getDefaultValue(schema, { status: 'error' });
|
||||
}
|
||||
/* Couldn't parse response data into APIResponse so we'll just return the data? */
|
||||
if (!apiResponse.success) {
|
||||
setReloadFlag();
|
||||
if (apiResponse.data) {
|
||||
return getDefaultValue(schema, { status: 'error' });
|
||||
}
|
||||
|
||||
return null as z.infer<T>;
|
||||
}
|
||||
return null as z.infer<T>;
|
||||
}
|
||||
|
||||
/* Caller asked for a raw response */
|
||||
if (options?.raw) {
|
||||
setReloadFlag();
|
||||
return apiResponse.data as z.infer<T>;
|
||||
}
|
||||
/* Caller asked for a raw response */
|
||||
if (options?.raw) {
|
||||
setReloadFlag();
|
||||
return apiResponse.data as z.infer<T>;
|
||||
}
|
||||
|
||||
if (apiResponse.data.data) {
|
||||
const parsedResult = schema.safeParse(apiResponse.data.data);
|
||||
if (parsedResult.success) {
|
||||
setReloadFlag();
|
||||
return parsedResult.data;
|
||||
} else {
|
||||
adze.withEmoji.warn('Zod Validation Error', parsedResult.error);
|
||||
setReloadFlag();
|
||||
return getDefaultValue(schema, apiResponse.data);
|
||||
}
|
||||
}
|
||||
if (apiResponse.data.data) {
|
||||
const parsedResult = schema.safeParse(apiResponse.data.data);
|
||||
if (parsedResult.success) {
|
||||
setReloadFlag();
|
||||
return parsedResult.data;
|
||||
} else {
|
||||
console.warn('Zod Validation Error', parsedResult.error);
|
||||
setReloadFlag();
|
||||
return getDefaultValue(schema, apiResponse.data);
|
||||
}
|
||||
}
|
||||
|
||||
setReloadFlag();
|
||||
return getDefaultValue(schema, apiResponse.data);
|
||||
} catch (error) {
|
||||
setReloadFlag();
|
||||
adze.withEmoji.error('API Request Error', error);
|
||||
return getDefaultValue(schema, { status: 'error' });
|
||||
}
|
||||
setReloadFlag();
|
||||
return getDefaultValue(schema, apiResponse.data);
|
||||
} catch (error) {
|
||||
setReloadFlag();
|
||||
console.error('API Request Error', error);
|
||||
return getDefaultValue(schema, { status: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultValue<T extends z.ZodType>(schema: T, response: APIResponse): z.infer<T> {
|
||||
if (schema instanceof z.ZodArray) {
|
||||
return [] as z.infer<T>;
|
||||
}
|
||||
if (schema instanceof z.ZodArray) {
|
||||
return [] as z.infer<T>;
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodObject) {
|
||||
return response as z.infer<T>;
|
||||
}
|
||||
if (schema instanceof z.ZodObject) {
|
||||
return response as z.infer<T>;
|
||||
}
|
||||
|
||||
return undefined as z.infer<T>;
|
||||
return undefined as z.infer<T>;
|
||||
}
|
||||
|
||||
export async function cachedFetch<T>(
|
||||
key: string,
|
||||
fetchFunction: () => Promise<T>,
|
||||
duration: number,
|
||||
onlyCache?: boolean
|
||||
key: string,
|
||||
fetchFunction: () => Promise<T>,
|
||||
duration: number,
|
||||
onlyCache?: boolean
|
||||
): Promise<T> {
|
||||
const now = Date.now();
|
||||
const entry = await kvStorage.getItem<T>(key);
|
||||
const now = Date.now();
|
||||
const entry = await kvStorage.getItem<T>(key);
|
||||
|
||||
if (entry && entry.data !== null) {
|
||||
const isFresh = now - entry.timestamp < duration;
|
||||
const data = entry.data;
|
||||
if (entry && entry.data !== null) {
|
||||
const isFresh = now - entry.timestamp < duration;
|
||||
const data = entry.data;
|
||||
|
||||
const looksLikeError =
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'status' in data &&
|
||||
(data as any).status === 'error';
|
||||
const looksLikeError =
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'status' in data &&
|
||||
(data as any).status === 'error';
|
||||
|
||||
if (isFresh && !looksLikeError) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
if (isFresh && !looksLikeError) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
if (onlyCache) {
|
||||
return null as T;
|
||||
}
|
||||
if (onlyCache) {
|
||||
return null as T;
|
||||
}
|
||||
|
||||
const data = await fetchFunction();
|
||||
const data = await fetchFunction();
|
||||
|
||||
if (
|
||||
!data ||
|
||||
typeof data !== 'object' ||
|
||||
!('status' in data) ||
|
||||
(data as any).status !== 'error'
|
||||
) {
|
||||
await kvStorage.setItem(key, data);
|
||||
}
|
||||
if (
|
||||
!data ||
|
||||
typeof data !== 'object' ||
|
||||
!('status' in data) ||
|
||||
(data as any).status !== 'error'
|
||||
) {
|
||||
await kvStorage.setItem(key, data);
|
||||
}
|
||||
|
||||
return data;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getCache<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const entry = await kvStorage.getItem<T>(key);
|
||||
return entry?.data ?? null;
|
||||
} catch (error) {
|
||||
console.error(`Failed to read cached data for key "${key}"`, error);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const entry = await kvStorage.getItem<T>(key);
|
||||
return entry?.data ?? null;
|
||||
} catch (error) {
|
||||
console.error(`Failed to read cached data for key "${key}"`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCache<T>(key: string, obj: T): Promise<void> {
|
||||
try {
|
||||
await kvStorage.setItem(key, obj);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update cached data for key "${key}"`, error);
|
||||
}
|
||||
try {
|
||||
await kvStorage.setItem(key, obj);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update cached data for key "${key}"`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function isAPIResponse(obj: any): obj is APIResponse {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj.status === 'string' &&
|
||||
(typeof obj.message === 'string' || typeof obj.error === 'string')
|
||||
);
|
||||
return (
|
||||
obj &&
|
||||
typeof obj.status === 'string' &&
|
||||
(typeof obj.message === 'string' || typeof obj.error === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
export function handleAPIError(result: APIResponse): void {
|
||||
adze.withEmoji.error('API Error', result);
|
||||
console.error('API Error', result);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const sourceLocale = 'en';
|
||||
export const otherLocales = ['mal', 'hi', 'zh-CN'];
|
||||
export const locales = ['en', 'mal', 'hi', 'zh-CN'];
|
||||
export const sourceLocale = 'en'
|
||||
export const otherLocales = ['mal','hi','zh-CN']
|
||||
export const locales = ['en','mal','hi','zh-CN']
|
||||
+24
-20
@@ -3753,10 +3753,8 @@ msgstr "AShift"
|
||||
msgid "Select ASHIFT"
|
||||
msgstr "Select ASHIFT"
|
||||
|
||||
#. placeholder {0}: data.ctId
|
||||
#: src/routes/[node]/jail/[node]/console/+page.svelte
|
||||
msgid "Jail console connected for jail {0}"
|
||||
msgstr "Jail console connected for jail {0}"
|
||||
#~ msgid "Jail console connected for jail {0}"
|
||||
#~ msgstr "Jail console connected for jail {0}"
|
||||
|
||||
#: src/routes/[node]/jail/[node]/console/+page.svelte
|
||||
msgid "The Jail is currently powered off.<0/> Start the Jail to access its console."
|
||||
@@ -7309,10 +7307,8 @@ msgstr "<0/> TPM"
|
||||
msgid "No Password"
|
||||
msgstr "No Password"
|
||||
|
||||
#. placeholder {0}: data.rid
|
||||
#: src/routes/[node]/vm/[node]/console/+page.svelte
|
||||
msgid "Serial console connected for VM {0}"
|
||||
msgstr "Serial console connected for VM {0}"
|
||||
#~ msgid "Serial console connected for VM {0}"
|
||||
#~ msgstr "Serial console connected for VM {0}"
|
||||
|
||||
#~ msgid "Shell"
|
||||
#~ msgstr "Shell"
|
||||
@@ -7347,9 +7343,8 @@ msgstr "Host Console Settings"
|
||||
#~ msgid "Console Settings"
|
||||
#~ msgstr "Console Settings"
|
||||
|
||||
#: src/routes/[node]/terminal/+page.svelte
|
||||
msgid "Host console connected"
|
||||
msgstr "Host console connected"
|
||||
#~ msgid "Host console connected"
|
||||
#~ msgstr "Host console connected"
|
||||
|
||||
#: src/routes/[node]/terminal/+page.svelte
|
||||
msgid "The host console has been disconnected.<0/> Click the \"Reconnect\" button to start a new session."
|
||||
@@ -7479,15 +7474,14 @@ msgstr "Failed to modify QEMU Guest Agent setting"
|
||||
msgid "Modified QEMU Guest Agent setting"
|
||||
msgstr "Modified QEMU Guest Agent setting"
|
||||
|
||||
#: src/lib/components/custom/VM/Options/QemuGuestAgent.svelte
|
||||
msgid ""
|
||||
"Enable this option to provide a QEMU Guest Agent channel via a virtio-console device.\n"
|
||||
"This improves guest integration for features like shutdown, status, and filesystem operations,\n"
|
||||
"when the guest agent is installed inside the VM."
|
||||
msgstr ""
|
||||
"Enable this option to provide a QEMU Guest Agent channel via a virtio-console device.\n"
|
||||
"This improves guest integration for features like shutdown, status, and filesystem operations,\n"
|
||||
"when the guest agent is installed inside the VM."
|
||||
#~ msgid ""
|
||||
#~ "Enable this option to provide a QEMU Guest Agent channel via a virtio-console device.\n"
|
||||
#~ "This improves guest integration for features like shutdown, status, and filesystem operations,\n"
|
||||
#~ "when the guest agent is installed inside the VM."
|
||||
#~ msgstr ""
|
||||
#~ "Enable this option to provide a QEMU Guest Agent channel via a virtio-console device.\n"
|
||||
#~ "This improves guest integration for features like shutdown, status, and filesystem operations,\n"
|
||||
#~ "when the guest agent is installed inside the VM."
|
||||
|
||||
#: src/lib/components/custom/VM/Options/QemuGuestAgent.svelte
|
||||
msgid "Enable QEMU Guest Agent"
|
||||
@@ -9687,3 +9681,13 @@ msgstr "Triggering"
|
||||
|
||||
#~ msgid "Trigger Failover 1"
|
||||
#~ msgstr "Trigger Failover 1"
|
||||
|
||||
#: src/lib/components/custom/VM/Options/QemuGuestAgent.svelte
|
||||
msgid ""
|
||||
"Enable this option to provide a QEMU Guest Agent channel via a virtio-console device. This\n"
|
||||
"improves guest integration for features like shutdown, status, and filesystem operations, when\n"
|
||||
"the guest agent is installed inside the VM."
|
||||
msgstr ""
|
||||
"Enable this option to provide a QEMU Guest Agent channel via a virtio-console device. This\n"
|
||||
"improves guest integration for features like shutdown, status, and filesystem operations, when\n"
|
||||
"the guest agent is installed inside the VM."
|
||||
|
||||
+18
-17
@@ -3733,10 +3733,8 @@ msgstr "एशिफ्ट"
|
||||
msgid "Select ASHIFT"
|
||||
msgstr "एशिफ्ट चुनें"
|
||||
|
||||
#. placeholder {0}: data.ctId
|
||||
#: src/routes/[node]/jail/[node]/console/+page.svelte
|
||||
msgid "Jail console connected for jail {0}"
|
||||
msgstr "\"जेल कंसोल {0} के लिए कनेक्टेड है\""
|
||||
#~ msgid "Jail console connected for jail {0}"
|
||||
#~ msgstr "\"जेल कंसोल {0} के लिए कनेक्टेड है\""
|
||||
|
||||
#: src/routes/[node]/jail/[node]/console/+page.svelte
|
||||
msgid "The Jail is currently powered off.<0/> Start the Jail to access its console."
|
||||
@@ -7272,10 +7270,8 @@ msgstr ""
|
||||
msgid "No Password"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.rid
|
||||
#: src/routes/[node]/vm/[node]/console/+page.svelte
|
||||
msgid "Serial console connected for VM {0}"
|
||||
msgstr ""
|
||||
#~ msgid "Serial console connected for VM {0}"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Shell"
|
||||
#~ msgstr ""
|
||||
@@ -7310,9 +7306,8 @@ msgstr ""
|
||||
#~ msgid "Console Settings"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/routes/[node]/terminal/+page.svelte
|
||||
msgid "Host console connected"
|
||||
msgstr ""
|
||||
#~ msgid "Host console connected"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/routes/[node]/terminal/+page.svelte
|
||||
msgid "The host console has been disconnected.<0/> Click the \"Reconnect\" button to start a new session."
|
||||
@@ -7446,12 +7441,11 @@ msgstr ""
|
||||
msgid "Modified QEMU Guest Agent setting"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/components/custom/VM/Options/QemuGuestAgent.svelte
|
||||
msgid ""
|
||||
"Enable this option to provide a QEMU Guest Agent channel via a virtio-console device.\n"
|
||||
"This improves guest integration for features like shutdown, status, and filesystem operations,\n"
|
||||
"when the guest agent is installed inside the VM."
|
||||
msgstr ""
|
||||
#~ msgid ""
|
||||
#~ "Enable this option to provide a QEMU Guest Agent channel via a virtio-console device.\n"
|
||||
#~ "This improves guest integration for features like shutdown, status, and filesystem operations,\n"
|
||||
#~ "when the guest agent is installed inside the VM."
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/lib/components/custom/VM/Options/QemuGuestAgent.svelte
|
||||
msgid "Enable QEMU Guest Agent"
|
||||
@@ -9551,3 +9545,10 @@ msgstr ""
|
||||
|
||||
#~ msgid "Trigger Failover 1"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/lib/components/custom/VM/Options/QemuGuestAgent.svelte
|
||||
msgid ""
|
||||
"Enable this option to provide a QEMU Guest Agent channel via a virtio-console device. This\n"
|
||||
"improves guest integration for features like shutdown, status, and filesystem operations, when\n"
|
||||
"the guest agent is installed inside the VM."
|
||||
msgstr ""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { loadCatalog, loadIDs } from './.wuchale/js.proxy.js';
|
||||
import { registerLoaders } from 'wuchale/load-utils';
|
||||
import { loadCatalog, loadIDs } from './.wuchale/js.proxy.js'
|
||||
import { registerLoaders } from 'wuchale/load-utils'
|
||||
|
||||
const key = 'js';
|
||||
const key = 'js'
|
||||
|
||||
// two exports. can be used anywhere
|
||||
export const getRuntime = registerLoaders(key, loadCatalog, loadIDs);
|
||||
export const getRuntimeRx = getRuntime;
|
||||
export const getRuntime = registerLoaders(key, loadCatalog, loadIDs)
|
||||
export const getRuntimeRx = getRuntime
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { loadCatalog, loadIDs } from './.wuchale/js.proxy.sync.js';
|
||||
import { currentRuntime } from 'wuchale/load-utils/server';
|
||||
import { loadCatalog, loadIDs } from './.wuchale/js.proxy.sync.js'
|
||||
import { currentRuntime } from 'wuchale/load-utils/server'
|
||||
|
||||
export const key = 'js';
|
||||
export { loadCatalog, loadIDs }; // for loading before runWithLocale
|
||||
export const key = 'js'
|
||||
export { loadCatalog, loadIDs } // for loading before runWithLocale
|
||||
|
||||
// two exports, same function
|
||||
export const getRuntime = (/** @type {string} */ loadID) => currentRuntime(key, loadID);
|
||||
export const getRuntimeRx = getRuntime;
|
||||
export const getRuntime = (/** @type {string} */ loadID) => currentRuntime(key, loadID)
|
||||
export const getRuntimeRx = getRuntime
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { loadCatalog, loadIDs } from './.wuchale/main.proxy.sync.js';
|
||||
import { currentRuntime } from 'wuchale/load-utils/server';
|
||||
import { loadCatalog, loadIDs } from './.wuchale/main.proxy.sync.js'
|
||||
import { currentRuntime } from 'wuchale/load-utils/server'
|
||||
|
||||
const key = 'main';
|
||||
const key = 'main'
|
||||
|
||||
export { loadCatalog, loadIDs, key }; // for hooks.server.{js,ts}
|
||||
export { loadCatalog, loadIDs, key } // for hooks.server.{js,ts}
|
||||
|
||||
// for non-reactive
|
||||
export const getRuntime = (/** @type {string} */ loadID) => currentRuntime(key, loadID);
|
||||
export const getRuntime = (/** @type {string} */ loadID) => currentRuntime(key, loadID)
|
||||
|
||||
// same function, only will be inside $derived when used
|
||||
export const getRuntimeRx = getRuntime;
|
||||
export const getRuntimeRx = getRuntime
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { loadCatalog, loadIDs } from './.wuchale/main.proxy.js';
|
||||
import { registerLoaders, defaultCollection } from 'wuchale/load-utils';
|
||||
import { loadCatalog, loadIDs } from './.wuchale/main.proxy.js'
|
||||
import { registerLoaders, defaultCollection } from 'wuchale/load-utils'
|
||||
|
||||
const key = 'main';
|
||||
const key = 'main'
|
||||
|
||||
const runtimes = $state({});
|
||||
const runtimes = $state({})
|
||||
|
||||
// for non-reactive
|
||||
export const getRuntime = registerLoaders(key, loadCatalog, loadIDs, defaultCollection(runtimes));
|
||||
export const getRuntime = registerLoaders(key, loadCatalog, loadIDs, defaultCollection(runtimes))
|
||||
|
||||
// same function, only will be inside $derived when used
|
||||
export const getRuntimeRx = getRuntime;
|
||||
export const getRuntimeRx = getRuntime
|
||||
|
||||
+18
-17
@@ -3741,10 +3741,8 @@ msgstr "AShift"
|
||||
msgid "Select ASHIFT"
|
||||
msgstr "AShift തിരഞ്ഞെടുക്കുക"
|
||||
|
||||
#. placeholder {0}: data.ctId
|
||||
#: src/routes/[node]/jail/[node]/console/+page.svelte
|
||||
msgid "Jail console connected for jail {0}"
|
||||
msgstr "ജയിലിനായുള്ള ജയിൽ കൺസോൾ {0} ബന്ധിപ്പിച്ചു"
|
||||
#~ msgid "Jail console connected for jail {0}"
|
||||
#~ msgstr "ജയിലിനായുള്ള ജയിൽ കൺസോൾ {0} ബന്ധിപ്പിച്ചു"
|
||||
|
||||
#: src/routes/[node]/jail/[node]/console/+page.svelte
|
||||
msgid "The Jail is currently powered off.<0/> Start the Jail to access its console."
|
||||
@@ -7256,10 +7254,8 @@ msgstr ""
|
||||
msgid "No Password"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.rid
|
||||
#: src/routes/[node]/vm/[node]/console/+page.svelte
|
||||
msgid "Serial console connected for VM {0}"
|
||||
msgstr ""
|
||||
#~ msgid "Serial console connected for VM {0}"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Shell"
|
||||
#~ msgstr ""
|
||||
@@ -7294,9 +7290,8 @@ msgstr ""
|
||||
#~ msgid "Console Settings"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/routes/[node]/terminal/+page.svelte
|
||||
msgid "Host console connected"
|
||||
msgstr ""
|
||||
#~ msgid "Host console connected"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/routes/[node]/terminal/+page.svelte
|
||||
msgid "The host console has been disconnected.<0/> Click the \"Reconnect\" button to start a new session."
|
||||
@@ -7430,12 +7425,11 @@ msgstr ""
|
||||
msgid "Modified QEMU Guest Agent setting"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/components/custom/VM/Options/QemuGuestAgent.svelte
|
||||
msgid ""
|
||||
"Enable this option to provide a QEMU Guest Agent channel via a virtio-console device.\n"
|
||||
"This improves guest integration for features like shutdown, status, and filesystem operations,\n"
|
||||
"when the guest agent is installed inside the VM."
|
||||
msgstr ""
|
||||
#~ msgid ""
|
||||
#~ "Enable this option to provide a QEMU Guest Agent channel via a virtio-console device.\n"
|
||||
#~ "This improves guest integration for features like shutdown, status, and filesystem operations,\n"
|
||||
#~ "when the guest agent is installed inside the VM."
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/lib/components/custom/VM/Options/QemuGuestAgent.svelte
|
||||
msgid "Enable QEMU Guest Agent"
|
||||
@@ -9599,3 +9593,10 @@ msgstr ""
|
||||
|
||||
#~ msgid "Trigger Failover 1"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/lib/components/custom/VM/Options/QemuGuestAgent.svelte
|
||||
msgid ""
|
||||
"Enable this option to provide a QEMU Guest Agent channel via a virtio-console device. This\n"
|
||||
"improves guest integration for features like shutdown, status, and filesystem operations, when\n"
|
||||
"the guest agent is installed inside the VM."
|
||||
msgstr ""
|
||||
|
||||
+18
-17
@@ -3679,10 +3679,8 @@ msgstr "扇区大小(AShift)"
|
||||
msgid "Select ASHIFT"
|
||||
msgstr "选择块对齐参数(ASHIFT)"
|
||||
|
||||
#. placeholder {0}: data.ctId
|
||||
#: src/routes/[node]/jail/[node]/console/+page.svelte
|
||||
msgid "Jail console connected for jail {0}"
|
||||
msgstr "已连接 Jail 控制台,Jail 为 {0}"
|
||||
#~ msgid "Jail console connected for jail {0}"
|
||||
#~ msgstr "已连接 Jail 控制台,Jail 为 {0}"
|
||||
|
||||
#: src/routes/[node]/jail/[node]/console/+page.svelte
|
||||
msgid "The Jail is currently powered off.<0/> Start the Jail to access its console."
|
||||
@@ -6426,10 +6424,8 @@ msgstr ""
|
||||
msgid "No Password"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.rid
|
||||
#: src/routes/[node]/vm/[node]/console/+page.svelte
|
||||
msgid "Serial console connected for VM {0}"
|
||||
msgstr ""
|
||||
#~ msgid "Serial console connected for VM {0}"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Shell"
|
||||
#~ msgstr ""
|
||||
@@ -6468,9 +6464,8 @@ msgstr ""
|
||||
#~ msgid "Console Settings"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/routes/[node]/terminal/+page.svelte
|
||||
msgid "Host console connected"
|
||||
msgstr ""
|
||||
#~ msgid "Host console connected"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/routes/[node]/terminal/+page.svelte
|
||||
msgid "The host console has been disconnected.<0/> Click the \"Reconnect\" button to start a new session."
|
||||
@@ -6610,12 +6605,11 @@ msgstr ""
|
||||
msgid "Modified QEMU Guest Agent setting"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/components/custom/VM/Options/QemuGuestAgent.svelte
|
||||
msgid ""
|
||||
"Enable this option to provide a QEMU Guest Agent channel via a virtio-console device.\n"
|
||||
"This improves guest integration for features like shutdown, status, and filesystem operations,\n"
|
||||
"when the guest agent is installed inside the VM."
|
||||
msgstr ""
|
||||
#~ msgid ""
|
||||
#~ "Enable this option to provide a QEMU Guest Agent channel via a virtio-console device.\n"
|
||||
#~ "This improves guest integration for features like shutdown, status, and filesystem operations,\n"
|
||||
#~ "when the guest agent is installed inside the VM."
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/lib/components/custom/VM/Options/QemuGuestAgent.svelte
|
||||
msgid "Enable QEMU Guest Agent"
|
||||
@@ -8795,3 +8789,10 @@ msgstr ""
|
||||
|
||||
#~ msgid "Trigger Failover 1"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/lib/components/custom/VM/Options/QemuGuestAgent.svelte
|
||||
msgid ""
|
||||
"Enable this option to provide a QEMU Guest Agent channel via a virtio-console device. This\n"
|
||||
"improves guest integration for features like shutdown, status, and filesystem operations, when\n"
|
||||
"the guest agent is installed inside the VM."
|
||||
msgstr ""
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import type { Jail, JailState } from '$lib/types/jail/jail';
|
||||
import { updateCache } from '$lib/utils/http';
|
||||
import { sha256, toHex } from '$lib/utils/string';
|
||||
import adze from 'adze';
|
||||
import { resource, useResizeObserver, PersistedState, useDebounce } from 'runed';
|
||||
import { onMount } from 'svelte';
|
||||
import { init as initGhostty, Terminal as GhosttyTerminal } from 'ghostty-web';
|
||||
@@ -233,7 +232,7 @@
|
||||
if (destroyed || activeConnectionToken !== connectionToken || terminal !== activeTerminal)
|
||||
return;
|
||||
|
||||
adze.info(`Jail console connected for jail ${data.ctId}`);
|
||||
console.log(`Jail console connected for jail ${data.ctId}`);
|
||||
if (lastWidth && lastHeight) {
|
||||
resizeTerminal(lastWidth, lastHeight);
|
||||
} else if (terminalContainer) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { storage } from '$lib';
|
||||
import { sha256, toHex } from '$lib/utils/string';
|
||||
import adze from 'adze';
|
||||
import { useResizeObserver, PersistedState, useDebounce } from 'runed';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { init as initGhostty, Terminal as GhosttyTerminal } from 'ghostty-web';
|
||||
@@ -134,7 +133,7 @@
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
adze.info(`Host console connected`);
|
||||
console.log(`Host console connected`);
|
||||
if (lastWidth && lastHeight) resizeTerminal(lastWidth, lastHeight);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { VM, VMDomain } from '$lib/types/vm/vm';
|
||||
import { toHex } from '$lib/utils/string';
|
||||
import { init as initGhostty, Terminal as GhosttyTerminal } from 'ghostty-web';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { getVmById, getVMDomain } from '$lib/api/vm/vm';
|
||||
import { updateCache } from '$lib/utils/http';
|
||||
import {
|
||||
@@ -17,7 +17,6 @@
|
||||
useResizeObserver
|
||||
} from 'runed';
|
||||
import { mode } from 'mode-watcher';
|
||||
import adze from 'adze';
|
||||
import { fade } from 'svelte/transition';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
|
||||
@@ -291,7 +290,7 @@
|
||||
|
||||
ws.onopen = async () => {
|
||||
connected = true;
|
||||
adze.info(`Serial console connected for VM ${data.rid}`);
|
||||
console.log(`Serial console connected for VM ${data.rid}`);
|
||||
if (lastWidth && lastHeight) {
|
||||
resizeTerminal(lastWidth, lastHeight);
|
||||
} else if (terminalContainer) {
|
||||
|
||||
@@ -223,12 +223,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer></Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<div class="px-4">
|
||||
<Card.Root class="gap-2">
|
||||
<Card.Root class="gap-2 pb-0">
|
||||
<Card.Header>
|
||||
<Card.Title>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -274,7 +273,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer></Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -320,7 +318,6 @@
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Card.Content>
|
||||
<Card.Footer></Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user