mirror of
https://github.com/lone-cloud/gerbil
synced 2026-06-03 19:54:44 -07:00
windows fixes of linux fixes of windows fixes
This commit is contained in:
parent
c0f1b4790c
commit
982d6e4951
8 changed files with 159 additions and 128 deletions
|
|
@ -40,34 +40,19 @@ export const BackendSelector = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadBackends = async () => {
|
const loadBackends = async () => {
|
||||||
try {
|
try {
|
||||||
const [currentBinaryInfo, cpuCapabilitiesResult, gpuCapabilities] =
|
const [cpuCapabilitiesResult, backends] = await Promise.all([
|
||||||
await Promise.all([
|
window.electronAPI.kobold.detectCPU(),
|
||||||
window.electronAPI.kobold.getCurrentBinaryInfo(),
|
window.electronAPI.kobold.getAvailableBackends(),
|
||||||
window.electronAPI.kobold.detectCPU(),
|
]);
|
||||||
window.electronAPI.kobold.detectGPUCapabilities(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setCpuCapabilities({
|
setCpuCapabilities({
|
||||||
avx: cpuCapabilitiesResult.avx,
|
avx: cpuCapabilitiesResult.avx,
|
||||||
avx2: cpuCapabilitiesResult.avx2,
|
avx2: cpuCapabilitiesResult.avx2,
|
||||||
});
|
});
|
||||||
|
|
||||||
let backends: Array<{
|
const cpuBackend = backends.find((b) => b.value === 'cpu');
|
||||||
value: string;
|
if (cpuBackend) {
|
||||||
label: string;
|
cpuBackend.devices = cpuCapabilitiesResult.devices;
|
||||||
devices?: string[];
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (currentBinaryInfo?.path) {
|
|
||||||
backends = await window.electronAPI.kobold.getAvailableBackends(
|
|
||||||
currentBinaryInfo.path,
|
|
||||||
gpuCapabilities
|
|
||||||
);
|
|
||||||
|
|
||||||
const cpuBackend = backends.find((b) => b.value === 'cpu');
|
|
||||||
if (cpuBackend) {
|
|
||||||
cpuBackend.devices = cpuCapabilitiesResult.devices;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvailableBackends(backends);
|
setAvailableBackends(backends);
|
||||||
|
|
@ -231,7 +216,7 @@ export const BackendSelector = ({
|
||||||
step={1}
|
step={1}
|
||||||
size="sm"
|
size="sm"
|
||||||
w={80}
|
w={80}
|
||||||
disabled={autoGpuLayers}
|
disabled={autoGpuLayers || backend === 'cpu'}
|
||||||
/>
|
/>
|
||||||
<Group gap="xs" align="center">
|
<Group gap="xs" align="center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|
@ -241,6 +226,7 @@ export const BackendSelector = ({
|
||||||
handleAutoGpuLayersChange(event.currentTarget.checked)
|
handleAutoGpuLayersChange(event.currentTarget.checked)
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={backend === 'cpu'}
|
||||||
/>
|
/>
|
||||||
<InfoTooltip label="Automatically try to allocate the GPU layers based on available VRAM." />
|
<InfoTooltip label="Automatically try to allocate the GPU layers based on available VRAM." />
|
||||||
</Group>
|
</Group>
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,7 @@ export const LaunchScreen = ({
|
||||||
|
|
||||||
const setHappyDefaults = useCallback(async () => {
|
const setHappyDefaults = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const backends = await window.electronAPI.kobold.getAvailableBackends(
|
const backends = await window.electronAPI.kobold.getAvailableBackends();
|
||||||
(await window.electronAPI.kobold.getCurrentBinaryInfo())?.path || '',
|
|
||||||
await window.electronAPI.kobold.detectGPUCapabilities()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!backend && backends.length > 0) {
|
if (!backend && backends.length > 0) {
|
||||||
handleBackendChange(backends[0].value);
|
handleBackendChange(backends[0].value);
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ class FriendlyKoboldApp {
|
||||||
this.windowManager = new WindowManager();
|
this.windowManager = new WindowManager();
|
||||||
this.githubService = new GitHubService(this.logManager);
|
this.githubService = new GitHubService(this.logManager);
|
||||||
this.hardwareService = new HardwareService();
|
this.hardwareService = new HardwareService();
|
||||||
this.binaryService = new BinaryService(this.logManager);
|
|
||||||
|
|
||||||
this.koboldManager = new KoboldCppManager(
|
this.koboldManager = new KoboldCppManager(
|
||||||
this.configManager,
|
this.configManager,
|
||||||
|
|
@ -44,6 +43,12 @@ class FriendlyKoboldApp {
|
||||||
this.logManager
|
this.logManager
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.binaryService = new BinaryService(
|
||||||
|
this.logManager,
|
||||||
|
this.koboldManager,
|
||||||
|
this.hardwareService
|
||||||
|
);
|
||||||
|
|
||||||
this.ipcHandlers = new IPCHandlers(
|
this.ipcHandlers = new IPCHandlers(
|
||||||
this.koboldManager,
|
this.koboldManager,
|
||||||
this.configManager,
|
this.configManager,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname } from 'path';
|
||||||
import { LogManager } from '@/main/managers/LogManager';
|
import { LogManager } from '@/main/managers/LogManager';
|
||||||
|
import type { KoboldCppManager } from '@/main/managers/KoboldCppManager';
|
||||||
|
import type { HardwareService } from '@/main/services/HardwareService';
|
||||||
|
|
||||||
export interface BackendSupport {
|
export interface BackendSupport {
|
||||||
rocm: boolean;
|
rocm: boolean;
|
||||||
|
|
@ -18,9 +20,17 @@ export class BinaryService {
|
||||||
Array<{ value: string; label: string; devices?: string[] }>
|
Array<{ value: string; label: string; devices?: string[] }>
|
||||||
>();
|
>();
|
||||||
private logManager: LogManager;
|
private logManager: LogManager;
|
||||||
|
private koboldManager: KoboldCppManager;
|
||||||
|
private hardwareService: HardwareService;
|
||||||
|
|
||||||
constructor(logManager: LogManager) {
|
constructor(
|
||||||
|
logManager: LogManager,
|
||||||
|
koboldManager: KoboldCppManager,
|
||||||
|
hardwareService: HardwareService
|
||||||
|
) {
|
||||||
this.logManager = logManager;
|
this.logManager = logManager;
|
||||||
|
this.koboldManager = koboldManager;
|
||||||
|
this.hardwareService = hardwareService;
|
||||||
}
|
}
|
||||||
|
|
||||||
detectBackendSupport(koboldBinaryPath: string): BackendSupport {
|
detectBackendSupport(koboldBinaryPath: string): BackendSupport {
|
||||||
|
|
@ -46,7 +56,18 @@ export class BinaryService {
|
||||||
|
|
||||||
const hasKoboldCppLib = (name: string) => {
|
const hasKoboldCppLib = (name: string) => {
|
||||||
const filename = `${name}${libExtension}`;
|
const filename = `${name}${libExtension}`;
|
||||||
return existsSync(join(internalDir, filename));
|
|
||||||
|
if (platform === 'win32') {
|
||||||
|
return (
|
||||||
|
existsSync(join(binaryDir, filename)) ||
|
||||||
|
existsSync(join(internalDir, filename))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
existsSync(join(internalDir, filename)) ||
|
||||||
|
existsSync(join(binaryDir, filename))
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
support.rocm = hasKoboldCppLib('koboldcpp_hipblas');
|
support.rocm = hasKoboldCppLib('koboldcpp_hipblas');
|
||||||
|
|
@ -66,67 +87,78 @@ export class BinaryService {
|
||||||
return support;
|
return support;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAvailableBackends(
|
async getAvailableBackends(): Promise<
|
||||||
koboldBinaryPath: string,
|
Array<{ value: string; label: string; devices?: string[] }>
|
||||||
hardwareCapabilities?: {
|
> {
|
||||||
cuda: { supported: boolean; devices: string[] };
|
try {
|
||||||
rocm: { supported: boolean; devices: string[] };
|
const [currentBinaryInfo, hardwareCapabilities] = await Promise.all([
|
||||||
vulkan: { supported: boolean; devices: string[] };
|
this.koboldManager.getCurrentBinaryInfo(),
|
||||||
clblast: { supported: boolean; devices: string[] };
|
this.hardwareService.detectGPUCapabilities(),
|
||||||
}
|
]);
|
||||||
): Array<{ value: string; label: string; devices?: string[] }> {
|
|
||||||
const cacheKey = `${koboldBinaryPath}:${JSON.stringify(hardwareCapabilities)}`;
|
|
||||||
|
|
||||||
if (this.availableBackendsCache.has(cacheKey)) {
|
if (!currentBinaryInfo?.path) {
|
||||||
return this.availableBackendsCache.get(cacheKey)!;
|
return [{ value: 'cpu', label: 'CPU' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendSupport = this.detectBackendSupport(koboldBinaryPath);
|
const cacheKey = `${currentBinaryInfo.path}:${JSON.stringify(hardwareCapabilities)}`;
|
||||||
const backends: Array<{
|
|
||||||
value: string;
|
if (this.availableBackendsCache.has(cacheKey)) {
|
||||||
label: string;
|
return this.availableBackendsCache.get(cacheKey)!;
|
||||||
devices?: string[];
|
}
|
||||||
}> = [];
|
|
||||||
|
const backendSupport = this.detectBackendSupport(currentBinaryInfo.path);
|
||||||
|
const backends: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
devices?: string[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (backendSupport.cuda && hardwareCapabilities.cuda.supported) {
|
||||||
|
backends.push({
|
||||||
|
value: 'cuda',
|
||||||
|
label: 'CUDA',
|
||||||
|
devices: hardwareCapabilities.cuda.devices,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backendSupport.rocm && hardwareCapabilities.rocm.supported) {
|
||||||
|
backends.push({
|
||||||
|
value: 'rocm',
|
||||||
|
label: 'ROCm',
|
||||||
|
devices: hardwareCapabilities.rocm.devices,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backendSupport.vulkan && hardwareCapabilities.vulkan.supported) {
|
||||||
|
backends.push({
|
||||||
|
value: 'vulkan',
|
||||||
|
label: 'Vulkan',
|
||||||
|
devices: hardwareCapabilities.vulkan.devices,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backendSupport.clblast && hardwareCapabilities.clblast.supported) {
|
||||||
|
backends.push({
|
||||||
|
value: 'clblast',
|
||||||
|
label: 'CLBlast',
|
||||||
|
devices: hardwareCapabilities.clblast.devices,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (backendSupport.cuda && hardwareCapabilities?.cuda.supported) {
|
|
||||||
backends.push({
|
backends.push({
|
||||||
value: 'cuda',
|
value: 'cpu',
|
||||||
label: 'CUDA',
|
label: 'CPU',
|
||||||
devices: hardwareCapabilities.cuda.devices,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.availableBackendsCache.set(cacheKey, backends);
|
||||||
|
return backends;
|
||||||
|
} catch (error) {
|
||||||
|
this.logManager.logError(
|
||||||
|
'Failed to get available backends:',
|
||||||
|
error as Error
|
||||||
|
);
|
||||||
|
return [{ value: 'cpu', label: 'CPU' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (backendSupport.rocm && hardwareCapabilities?.rocm.supported) {
|
|
||||||
backends.push({
|
|
||||||
value: 'rocm',
|
|
||||||
label: 'ROCm',
|
|
||||||
devices: hardwareCapabilities.rocm.devices,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (backendSupport.vulkan && hardwareCapabilities?.vulkan.supported) {
|
|
||||||
backends.push({
|
|
||||||
value: 'vulkan',
|
|
||||||
label: 'Vulkan',
|
|
||||||
devices: hardwareCapabilities.vulkan.devices,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (backendSupport.clblast && hardwareCapabilities?.clblast.supported) {
|
|
||||||
backends.push({
|
|
||||||
value: 'clblast',
|
|
||||||
label: 'CLBlast',
|
|
||||||
devices: hardwareCapabilities.clblast.devices,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
backends.push({
|
|
||||||
value: 'cpu',
|
|
||||||
label: 'CPU',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.availableBackendsCache.set(cacheKey, backends);
|
|
||||||
return backends;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCache(): void {
|
clearCache(): void {
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ export class HardwareService {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.gpuCapabilitiesCache = { cuda, rocm, vulkan, clblast };
|
this.gpuCapabilitiesCache = { cuda, rocm, vulkan, clblast };
|
||||||
|
|
||||||
return this.gpuCapabilitiesCache;
|
return this.gpuCapabilitiesCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,10 +304,14 @@ export class HardwareService {
|
||||||
const lines = output.split('\n');
|
const lines = output.split('\n');
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.includes('deviceName')) {
|
// Handle both formats: "deviceName = AMD Radeon RX 7900 GRE" and other potential formats
|
||||||
const name = line.split('=')[1]?.trim();
|
if (line.includes('deviceName') && line.includes('=')) {
|
||||||
if (name) {
|
const parts = line.split('=');
|
||||||
devices.push(shortenDeviceName(name));
|
if (parts.length >= 2) {
|
||||||
|
const name = parts[1]?.trim();
|
||||||
|
if (name) {
|
||||||
|
devices.push(shortenDeviceName(name));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -343,29 +348,23 @@ export class HardwareService {
|
||||||
const lines = output.split('\n');
|
const lines = output.split('\n');
|
||||||
|
|
||||||
let currentPlatform = '';
|
let currentPlatform = '';
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
// Extract platform name
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
|
||||||
|
// Extract platform name - this appears early in the output
|
||||||
if (line.includes('Platform Name:')) {
|
if (line.includes('Platform Name:')) {
|
||||||
currentPlatform = line.split('Platform Name:')[1]?.trim() || '';
|
currentPlatform = line.split('Platform Name:')[1]?.trim() || '';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract device names for GPU devices
|
// When we find a GPU device type, look for device name
|
||||||
if (line.includes('Device Name:')) {
|
if (line.includes('Device Type:') && line.includes('GPU')) {
|
||||||
const deviceName = line.split('Device Name:')[1]?.trim();
|
const deviceName = this.findDeviceNameInClInfo(lines, i);
|
||||||
if (deviceName) {
|
|
||||||
// Look ahead to check if this is a GPU device
|
if (deviceName && currentPlatform) {
|
||||||
for (let j = i + 1; j < Math.min(i + 10, lines.length); j++) {
|
const deviceLabel = `${shortenDeviceName(deviceName)} (${currentPlatform})`;
|
||||||
if (lines[j].includes('Device Type:') && lines[j].includes('GPU')) {
|
devices.push(deviceLabel);
|
||||||
const deviceLabel = currentPlatform
|
|
||||||
? `${shortenDeviceName(deviceName)} (${currentPlatform})`
|
|
||||||
: shortenDeviceName(deviceName);
|
|
||||||
devices.push(deviceLabel);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -373,6 +372,34 @@ export class HardwareService {
|
||||||
return devices;
|
return devices;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private findDeviceNameInClInfo(lines: string[], startIndex: number): string {
|
||||||
|
// Look for Board name first (appears closer to Device Type and is more descriptive)
|
||||||
|
for (
|
||||||
|
let j = startIndex + 1;
|
||||||
|
j < Math.min(startIndex + 50, lines.length);
|
||||||
|
j++
|
||||||
|
) {
|
||||||
|
const nextLine = lines[j].trim();
|
||||||
|
if (nextLine.includes('Board name:')) {
|
||||||
|
return nextLine.split('Board name:')[1]?.trim() || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no Board name found, look for Name: field (appears much later)
|
||||||
|
for (
|
||||||
|
let j = startIndex + 1;
|
||||||
|
j < Math.min(startIndex + 100, lines.length);
|
||||||
|
j++
|
||||||
|
) {
|
||||||
|
const nextLine = lines[j].trim();
|
||||||
|
if (nextLine.startsWith('Name:')) {
|
||||||
|
return nextLine.split('Name:')[1]?.trim() || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
private async detectCLBlast(): Promise<{
|
private async detectCLBlast(): Promise<{
|
||||||
supported: boolean;
|
supported: boolean;
|
||||||
devices: string[];
|
devices: string[];
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { LogManager } from '@/main/managers/LogManager';
|
||||||
import { GitHubService } from '@/main/services/GitHubService';
|
import { GitHubService } from '@/main/services/GitHubService';
|
||||||
import { HardwareService } from '@/main/services/HardwareService';
|
import { HardwareService } from '@/main/services/HardwareService';
|
||||||
import { BinaryService } from '@/main/services/BinaryService';
|
import { BinaryService } from '@/main/services/BinaryService';
|
||||||
import type { GPUCapabilities } from '@/types/hardware';
|
|
||||||
|
|
||||||
export class IPCHandlers {
|
export class IPCHandlers {
|
||||||
private koboldManager: KoboldCppManager;
|
private koboldManager: KoboldCppManager;
|
||||||
|
|
@ -125,13 +124,8 @@ export class IPCHandlers {
|
||||||
this.binaryService.detectBackendSupport(binaryPath)
|
this.binaryService.detectBackendSupport(binaryPath)
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle('kobold:getAvailableBackends', () =>
|
||||||
'kobold:getAvailableBackends',
|
this.binaryService.getAvailableBackends()
|
||||||
(_, binaryPath: string, hardwareCapabilities: GPUCapabilities) =>
|
|
||||||
this.binaryService.getAvailableBackends(
|
|
||||||
binaryPath,
|
|
||||||
hardwareCapabilities
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle('kobold:getPlatform', () => ({
|
ipcMain.handle('kobold:getPlatform', () => ({
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import type {
|
||||||
LogsAPI,
|
LogsAPI,
|
||||||
UpdateInfo,
|
UpdateInfo,
|
||||||
} from '@/types/electron';
|
} from '@/types/electron';
|
||||||
import type { GPUCapabilities } from '@/types/hardware';
|
|
||||||
|
|
||||||
const koboldAPI: KoboldAPI = {
|
const koboldAPI: KoboldAPI = {
|
||||||
getInstalledVersion: () => ipcRenderer.invoke('kobold:getInstalledVersion'),
|
getInstalledVersion: () => ipcRenderer.invoke('kobold:getInstalledVersion'),
|
||||||
|
|
@ -32,15 +31,7 @@ const koboldAPI: KoboldAPI = {
|
||||||
ipcRenderer.invoke('kobold:detectAllCapabilities'),
|
ipcRenderer.invoke('kobold:detectAllCapabilities'),
|
||||||
detectBackendSupport: (binaryPath: string) =>
|
detectBackendSupport: (binaryPath: string) =>
|
||||||
ipcRenderer.invoke('kobold:detectBackendSupport', binaryPath),
|
ipcRenderer.invoke('kobold:detectBackendSupport', binaryPath),
|
||||||
getAvailableBackends: (
|
getAvailableBackends: () => ipcRenderer.invoke('kobold:getAvailableBackends'),
|
||||||
binaryPath: string,
|
|
||||||
hardwareCapabilities: GPUCapabilities
|
|
||||||
) =>
|
|
||||||
ipcRenderer.invoke(
|
|
||||||
'kobold:getAvailableBackends',
|
|
||||||
binaryPath,
|
|
||||||
hardwareCapabilities
|
|
||||||
),
|
|
||||||
getCurrentInstallDir: () => ipcRenderer.invoke('kobold:getCurrentInstallDir'),
|
getCurrentInstallDir: () => ipcRenderer.invoke('kobold:getCurrentInstallDir'),
|
||||||
selectInstallDirectory: () =>
|
selectInstallDirectory: () =>
|
||||||
ipcRenderer.invoke('kobold:selectInstallDirectory'),
|
ipcRenderer.invoke('kobold:selectInstallDirectory'),
|
||||||
|
|
|
||||||
7
src/types/electron.d.ts
vendored
7
src/types/electron.d.ts
vendored
|
|
@ -81,10 +81,9 @@ export interface KoboldAPI {
|
||||||
failsafe: boolean;
|
failsafe: boolean;
|
||||||
cuda: boolean;
|
cuda: boolean;
|
||||||
}>;
|
}>;
|
||||||
getAvailableBackends: (
|
getAvailableBackends: () => Promise<
|
||||||
binaryPath: string,
|
Array<{ value: string; label: string; devices?: string[] }>
|
||||||
hardwareCapabilities: GPUCapabilities
|
>;
|
||||||
) => Promise<Array<{ value: string; label: string; devices?: string[] }>>;
|
|
||||||
getCurrentInstallDir: () => Promise<string>;
|
getCurrentInstallDir: () => Promise<string>;
|
||||||
selectInstallDirectory: () => Promise<string | null>;
|
selectInstallDirectory: () => Promise<string | null>;
|
||||||
downloadRelease: (
|
downloadRelease: (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue