mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 19:01:02 +01:00
feat(pcd): add database linking functionality
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"$server/": "./src/server/",
|
||||
"$db/": "./src/lib/server/db/",
|
||||
"$jobs/": "./src/lib/server/jobs/",
|
||||
"$pcd/": "./src/lib/server/pcd/",
|
||||
"$arr/": "./src/lib/server/utils/arr/",
|
||||
"$http/": "./src/lib/server/utils/http/",
|
||||
"$utils/": "./src/lib/server/utils/",
|
||||
|
||||
126
src/lib/client/ui/table/Table.svelte
Normal file
126
src/lib/client/ui/table/Table.svelte
Normal file
@@ -0,0 +1,126 @@
|
||||
<script lang="ts" generics="T extends Record<string, any>">
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
/**
|
||||
* Column definition for table
|
||||
*/
|
||||
export interface Column<T> {
|
||||
/** Unique key for the column */
|
||||
key: string;
|
||||
/** Header text to display */
|
||||
header: string;
|
||||
/** Optional width class (e.g., 'w-32', 'w-1/4') */
|
||||
width?: string;
|
||||
/** Text alignment */
|
||||
align?: 'left' | 'center' | 'right';
|
||||
/** Whether column is sortable */
|
||||
sortable?: boolean;
|
||||
/** Custom cell renderer - receives the full row object */
|
||||
cell?: (row: T) => string | ComponentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props
|
||||
*/
|
||||
export let columns: Column<T>[];
|
||||
export let data: T[];
|
||||
export let hoverable: boolean = true;
|
||||
export let compact: boolean = false;
|
||||
export let emptyMessage: string = 'No data available';
|
||||
|
||||
/**
|
||||
* Get cell value by key path (supports nested properties like 'user.name')
|
||||
*/
|
||||
function getCellValue(row: T, key: string): any {
|
||||
return key.split('.').reduce((obj, k) => obj?.[k], row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get alignment class
|
||||
*/
|
||||
function getAlignClass(align?: 'left' | 'center' | 'right'): string {
|
||||
switch (align) {
|
||||
case 'center':
|
||||
return 'text-center';
|
||||
case 'right':
|
||||
return 'text-right';
|
||||
default:
|
||||
return 'text-left';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||
<table class="w-full">
|
||||
<!-- Header -->
|
||||
<thead
|
||||
class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800"
|
||||
>
|
||||
<tr>
|
||||
{#each columns as column}
|
||||
<th
|
||||
class={`${compact ? 'px-4 py-2' : 'px-6 py-3'} text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300 ${getAlignClass(column.align)} ${column.width || ''}`}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
{/each}
|
||||
<!-- Actions column slot -->
|
||||
{#if $$slots.actions}
|
||||
<th
|
||||
class={`${compact ? 'px-4 py-2' : 'px-6 py-3'} text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300 text-right`}
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<!-- Body -->
|
||||
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
|
||||
{#if data.length === 0}
|
||||
<tr>
|
||||
<td
|
||||
colspan={columns.length + ($$slots.actions ? 1 : 0)}
|
||||
class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each data as row, rowIndex}
|
||||
<tr
|
||||
class={hoverable
|
||||
? 'transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-900'
|
||||
: ''}
|
||||
>
|
||||
{#each columns as column}
|
||||
<td
|
||||
class={`${compact ? 'px-4 py-2' : 'px-6 py-4'} text-sm text-neutral-900 dark:text-neutral-100 ${getAlignClass(column.align)} ${column.width || ''}`}
|
||||
>
|
||||
{#if column.cell}
|
||||
{@const rendered = column.cell(row)}
|
||||
{#if typeof rendered === 'string'}
|
||||
{rendered}
|
||||
{:else}
|
||||
<svelte:component this={rendered} {row} />
|
||||
{/if}
|
||||
{:else}
|
||||
<slot name="cell" {row} {column} {rowIndex}>
|
||||
{getCellValue(row, column.key)}
|
||||
</slot>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
|
||||
<!-- Actions slot -->
|
||||
{#if $$slots.actions}
|
||||
<td class={`${compact ? 'px-4 py-2' : 'px-6 py-4'} text-sm text-right`}>
|
||||
<slot name="actions" {row} {rowIndex} />
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -9,6 +9,9 @@ import { migration as migration004 } from './migrations/004_create_jobs_tables.t
|
||||
import { migration as migration005 } from './migrations/005_create_backup_settings.ts';
|
||||
import { migration as migration006 } from './migrations/006_simplify_log_settings.ts';
|
||||
import { migration as migration007 } from './migrations/007_create_notification_tables.ts';
|
||||
import { migration as migration008 } from './migrations/008_create_database_instances.ts';
|
||||
import { migration as migration009 } from './migrations/009_add_personal_access_token.ts';
|
||||
import { migration as migration010 } from './migrations/010_add_is_private.ts';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
@@ -233,7 +236,10 @@ export function loadMigrations(): Migration[] {
|
||||
migration004,
|
||||
migration005,
|
||||
migration006,
|
||||
migration007
|
||||
migration007,
|
||||
migration008,
|
||||
migration009,
|
||||
migration010
|
||||
];
|
||||
|
||||
// Sort by version number
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Migration } from '../migrations.ts';
|
||||
|
||||
/**
|
||||
* Migration 008: Create database_instances table
|
||||
*
|
||||
* Creates the table for storing linked Profilarr Compliant Database (PCD) repositories.
|
||||
* These databases contain configuration profiles that can be synced to arr instances.
|
||||
*
|
||||
* Fields:
|
||||
* - id: Auto-incrementing primary key
|
||||
* - uuid: Unique identifier used for filesystem storage path
|
||||
* - name: User-friendly name (unique)
|
||||
* - repository_url: Git repository URL
|
||||
* - local_path: Path where the repository is cloned (data/databases/{uuid})
|
||||
* - sync_strategy: 0 = manual check, >0 = auto-check every X minutes
|
||||
* - auto_pull: 0 = notify only, 1 = auto-pull updates
|
||||
* - enabled: Boolean flag (1=enabled, 0=disabled)
|
||||
* - last_synced_at: Timestamp of last successful sync
|
||||
* - created_at: Timestamp of creation
|
||||
* - updated_at: Timestamp of last update
|
||||
*/
|
||||
|
||||
export const migration: Migration = {
|
||||
version: 8,
|
||||
name: 'Create database_instances table',
|
||||
|
||||
up: `
|
||||
CREATE TABLE database_instances (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
-- Instance identification
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
|
||||
-- Repository connection
|
||||
repository_url TEXT NOT NULL,
|
||||
|
||||
-- Local storage
|
||||
local_path TEXT NOT NULL,
|
||||
|
||||
-- Sync settings
|
||||
sync_strategy INTEGER NOT NULL DEFAULT 0,
|
||||
auto_pull INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Status
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_synced_at DATETIME,
|
||||
|
||||
-- Metadata
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index for looking up by UUID
|
||||
CREATE INDEX idx_database_instances_uuid ON database_instances(uuid);
|
||||
`,
|
||||
|
||||
down: `
|
||||
DROP INDEX IF EXISTS idx_database_instances_uuid;
|
||||
DROP TABLE IF EXISTS database_instances;
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Migration } from '../migrations.ts';
|
||||
|
||||
/**
|
||||
* Migration 009: Add personal_access_token to database_instances
|
||||
*
|
||||
* Adds support for Personal Access Tokens (PAT) to enable:
|
||||
* - Cloning private repositories
|
||||
* - Push access for developers working on database content
|
||||
*/
|
||||
|
||||
export const migration: Migration = {
|
||||
version: 9,
|
||||
name: 'Add personal_access_token to database_instances',
|
||||
|
||||
up: `
|
||||
ALTER TABLE database_instances
|
||||
ADD COLUMN personal_access_token TEXT;
|
||||
`,
|
||||
|
||||
down: `
|
||||
-- SQLite doesn't support DROP COLUMN easily, so we recreate the table
|
||||
CREATE TABLE database_instances_backup (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
repository_url TEXT NOT NULL,
|
||||
local_path TEXT NOT NULL,
|
||||
sync_strategy INTEGER NOT NULL DEFAULT 0,
|
||||
auto_pull INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_synced_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO database_instances_backup
|
||||
SELECT id, uuid, name, repository_url, local_path, sync_strategy,
|
||||
auto_pull, enabled, last_synced_at, created_at, updated_at
|
||||
FROM database_instances;
|
||||
|
||||
DROP TABLE database_instances;
|
||||
|
||||
ALTER TABLE database_instances_backup RENAME TO database_instances;
|
||||
|
||||
CREATE INDEX idx_database_instances_uuid ON database_instances(uuid);
|
||||
`
|
||||
};
|
||||
48
src/lib/server/db/migrations/010_add_is_private.ts
Normal file
48
src/lib/server/db/migrations/010_add_is_private.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Migration } from '../migrations.ts';
|
||||
|
||||
/**
|
||||
* Migration 010: Add is_private to database_instances
|
||||
*
|
||||
* Adds auto-detected flag to indicate if a repository is private.
|
||||
* This is determined during the initial clone by attempting to access
|
||||
* the repository with and without authentication.
|
||||
*/
|
||||
|
||||
export const migration: Migration = {
|
||||
version: 10,
|
||||
name: 'Add is_private to database_instances',
|
||||
|
||||
up: `
|
||||
ALTER TABLE database_instances
|
||||
ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0;
|
||||
`,
|
||||
|
||||
down: `
|
||||
-- SQLite doesn't support DROP COLUMN easily, so we recreate the table
|
||||
CREATE TABLE database_instances_backup (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
repository_url TEXT NOT NULL,
|
||||
local_path TEXT NOT NULL,
|
||||
sync_strategy INTEGER NOT NULL DEFAULT 0,
|
||||
auto_pull INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
personal_access_token TEXT,
|
||||
last_synced_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO database_instances_backup
|
||||
SELECT id, uuid, name, repository_url, local_path, sync_strategy,
|
||||
auto_pull, enabled, personal_access_token, last_synced_at, created_at, updated_at
|
||||
FROM database_instances;
|
||||
|
||||
DROP TABLE database_instances;
|
||||
|
||||
ALTER TABLE database_instances_backup RENAME TO database_instances;
|
||||
|
||||
CREATE INDEX idx_database_instances_uuid ON database_instances(uuid);
|
||||
`
|
||||
};
|
||||
211
src/lib/server/db/queries/databaseInstances.ts
Normal file
211
src/lib/server/db/queries/databaseInstances.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { db } from '../db.ts';
|
||||
|
||||
/**
|
||||
* Types for database_instances table
|
||||
*/
|
||||
export interface DatabaseInstance {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
repository_url: string;
|
||||
local_path: string;
|
||||
sync_strategy: number;
|
||||
auto_pull: number;
|
||||
enabled: number;
|
||||
personal_access_token: string | null;
|
||||
is_private: number;
|
||||
last_synced_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateDatabaseInstanceInput {
|
||||
uuid: string;
|
||||
name: string;
|
||||
repositoryUrl: string;
|
||||
localPath: string;
|
||||
syncStrategy?: number;
|
||||
autoPull?: boolean;
|
||||
enabled?: boolean;
|
||||
personalAccessToken?: string;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateDatabaseInstanceInput {
|
||||
name?: string;
|
||||
repositoryUrl?: string;
|
||||
syncStrategy?: number;
|
||||
autoPull?: boolean;
|
||||
enabled?: boolean;
|
||||
personalAccessToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* All queries for database_instances table
|
||||
*/
|
||||
export const databaseInstancesQueries = {
|
||||
/**
|
||||
* Create a new database instance
|
||||
*/
|
||||
create(input: CreateDatabaseInstanceInput): number {
|
||||
const syncStrategy = input.syncStrategy ?? 0;
|
||||
const autoPull = input.autoPull !== false ? 1 : 0;
|
||||
const enabled = input.enabled !== false ? 1 : 0;
|
||||
const personalAccessToken = input.personalAccessToken || null;
|
||||
const isPrivate = input.isPrivate ? 1 : 0;
|
||||
|
||||
db.execute(
|
||||
`INSERT INTO database_instances (uuid, name, repository_url, local_path, sync_strategy, auto_pull, enabled, personal_access_token, is_private)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
input.uuid,
|
||||
input.name,
|
||||
input.repositoryUrl,
|
||||
input.localPath,
|
||||
syncStrategy,
|
||||
autoPull,
|
||||
enabled,
|
||||
personalAccessToken,
|
||||
isPrivate
|
||||
);
|
||||
|
||||
// Get the last inserted ID
|
||||
const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id');
|
||||
return result?.id ?? 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a database instance by ID
|
||||
*/
|
||||
getById(id: number): DatabaseInstance | undefined {
|
||||
return db.queryFirst<DatabaseInstance>('SELECT * FROM database_instances WHERE id = ?', id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a database instance by UUID
|
||||
*/
|
||||
getByUuid(uuid: string): DatabaseInstance | undefined {
|
||||
return db.queryFirst<DatabaseInstance>(
|
||||
'SELECT * FROM database_instances WHERE uuid = ?',
|
||||
uuid
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all database instances
|
||||
*/
|
||||
getAll(): DatabaseInstance[] {
|
||||
return db.query<DatabaseInstance>('SELECT * FROM database_instances ORDER BY name');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get enabled database instances
|
||||
*/
|
||||
getEnabled(): DatabaseInstance[] {
|
||||
return db.query<DatabaseInstance>(
|
||||
'SELECT * FROM database_instances WHERE enabled = 1 ORDER BY name'
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get databases that need auto-sync check
|
||||
*/
|
||||
getDueForSync(): DatabaseInstance[] {
|
||||
return db.query<DatabaseInstance>(
|
||||
`SELECT * FROM database_instances
|
||||
WHERE enabled = 1
|
||||
AND sync_strategy > 0
|
||||
AND (
|
||||
last_synced_at IS NULL
|
||||
OR datetime(last_synced_at, '+' || sync_strategy || ' minutes') <= datetime('now')
|
||||
)
|
||||
ORDER BY last_synced_at ASC NULLS FIRST`
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a database instance
|
||||
*/
|
||||
update(id: number, input: UpdateDatabaseInstanceInput): boolean {
|
||||
const updates: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
|
||||
if (input.name !== undefined) {
|
||||
updates.push('name = ?');
|
||||
params.push(input.name);
|
||||
}
|
||||
if (input.repositoryUrl !== undefined) {
|
||||
updates.push('repository_url = ?');
|
||||
params.push(input.repositoryUrl);
|
||||
}
|
||||
if (input.syncStrategy !== undefined) {
|
||||
updates.push('sync_strategy = ?');
|
||||
params.push(input.syncStrategy);
|
||||
}
|
||||
if (input.autoPull !== undefined) {
|
||||
updates.push('auto_pull = ?');
|
||||
params.push(input.autoPull ? 1 : 0);
|
||||
}
|
||||
if (input.enabled !== undefined) {
|
||||
updates.push('enabled = ?');
|
||||
params.push(input.enabled ? 1 : 0);
|
||||
}
|
||||
if (input.personalAccessToken !== undefined) {
|
||||
updates.push('personal_access_token = ?');
|
||||
params.push(input.personalAccessToken || null);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add updated_at
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
params.push(id);
|
||||
|
||||
const affected = db.execute(
|
||||
`UPDATE database_instances SET ${updates.join(', ')} WHERE id = ?`,
|
||||
...params
|
||||
);
|
||||
|
||||
return affected > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update last_synced_at timestamp
|
||||
*/
|
||||
updateSyncedAt(id: number): boolean {
|
||||
const affected = db.execute(
|
||||
'UPDATE database_instances SET last_synced_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
id
|
||||
);
|
||||
return affected > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a database instance
|
||||
*/
|
||||
delete(id: number): boolean {
|
||||
const affected = db.execute('DELETE FROM database_instances WHERE id = ?', id);
|
||||
return affected > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a database name already exists
|
||||
*/
|
||||
nameExists(name: string, excludeId?: number): boolean {
|
||||
if (excludeId !== undefined) {
|
||||
const result = db.queryFirst<{ count: number }>(
|
||||
'SELECT COUNT(*) as count FROM database_instances WHERE name = ? AND id != ?',
|
||||
name,
|
||||
excludeId
|
||||
);
|
||||
return (result?.count ?? 0) > 0;
|
||||
}
|
||||
|
||||
const result = db.queryFirst<{ count: number }>(
|
||||
'SELECT COUNT(*) as count FROM database_instances WHERE name = ?',
|
||||
name
|
||||
);
|
||||
return (result?.count ?? 0) > 0;
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
-- Profilarr Database Schema
|
||||
-- This file documents the current database schema after all migrations
|
||||
-- DO NOT execute this file directly - use migrations instead
|
||||
-- Last updated: 2025-10-22
|
||||
-- Last updated: 2025-11-04
|
||||
|
||||
-- ==============================================================================
|
||||
-- TABLE: migrations
|
||||
@@ -191,6 +191,40 @@ CREATE TABLE notification_history (
|
||||
FOREIGN KEY (service_id) REFERENCES notification_services(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ==============================================================================
|
||||
-- TABLE: database_instances
|
||||
-- Purpose: Store linked Profilarr Compliant Database (PCD) repositories
|
||||
-- Migration: 008_create_database_instances.ts, 009_add_personal_access_token.ts, 010_add_is_private.ts
|
||||
-- ==============================================================================
|
||||
|
||||
CREATE TABLE database_instances (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
-- Instance identification
|
||||
uuid TEXT NOT NULL UNIQUE, -- UUID for filesystem storage path
|
||||
name TEXT NOT NULL UNIQUE, -- User-friendly name (e.g., "Dictionarry DB")
|
||||
|
||||
-- Repository connection
|
||||
repository_url TEXT NOT NULL, -- Git repository URL
|
||||
personal_access_token TEXT, -- PAT for private repos and push access (Migration 009)
|
||||
is_private INTEGER NOT NULL DEFAULT 0, -- 1=private repo, 0=public (auto-detected, Migration 010)
|
||||
|
||||
-- Local storage
|
||||
local_path TEXT NOT NULL, -- Path where repo is cloned (data/databases/{uuid})
|
||||
|
||||
-- Sync settings
|
||||
sync_strategy INTEGER NOT NULL DEFAULT 0, -- 0=manual check, >0=auto-check every X minutes
|
||||
auto_pull INTEGER NOT NULL DEFAULT 0, -- 0=notify only, 1=auto-pull updates
|
||||
|
||||
-- Status
|
||||
enabled INTEGER NOT NULL DEFAULT 1, -- 1=enabled, 0=disabled
|
||||
last_synced_at DATETIME, -- Timestamp of last successful sync
|
||||
|
||||
-- Metadata
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ==============================================================================
|
||||
-- INDEXES
|
||||
-- Purpose: Improve query performance
|
||||
@@ -212,3 +246,6 @@ CREATE INDEX idx_notification_services_type ON notification_services(service_typ
|
||||
CREATE INDEX idx_notification_history_service_id ON notification_history(service_id);
|
||||
CREATE INDEX idx_notification_history_sent_at ON notification_history(sent_at);
|
||||
CREATE INDEX idx_notification_history_status ON notification_history(status);
|
||||
|
||||
-- Database instances indexes (Migration: 008_create_database_instances.ts)
|
||||
CREATE INDEX idx_database_instances_uuid ON database_instances(uuid);
|
||||
|
||||
60
src/lib/server/jobs/definitions/syncDatabases.ts
Normal file
60
src/lib/server/jobs/definitions/syncDatabases.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { logger } from '$logger/logger.ts';
|
||||
import { syncDatabases } from '../logic/syncDatabases.ts';
|
||||
import type { JobDefinition, JobResult } from '../types.ts';
|
||||
|
||||
/**
|
||||
* Sync PCD databases job
|
||||
* Checks for databases that need syncing and pulls updates if auto_pull is enabled
|
||||
*/
|
||||
export const syncDatabasesJob: JobDefinition = {
|
||||
name: 'sync_databases',
|
||||
description: 'Auto-sync PCD databases with remote repositories',
|
||||
schedule: '*/5 minutes',
|
||||
|
||||
handler: async (): Promise<JobResult> => {
|
||||
try {
|
||||
await logger.info('Starting database sync job', {
|
||||
source: 'SyncDatabasesJob'
|
||||
});
|
||||
|
||||
// Run sync
|
||||
const result = await syncDatabases();
|
||||
|
||||
// Log results for each database
|
||||
for (const db of result.databases) {
|
||||
if (db.success) {
|
||||
if (db.updatesPulled > 0) {
|
||||
await logger.info(`Synced database: ${db.name}`, {
|
||||
source: 'SyncDatabasesJob',
|
||||
meta: { databaseId: db.id, updatesPulled: db.updatesPulled }
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await logger.error(`Failed to sync database: ${db.name}`, {
|
||||
source: 'SyncDatabasesJob',
|
||||
meta: { databaseId: db.id, error: db.error }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const message = `Sync completed: ${result.successCount} successful, ${result.failureCount} failed (${result.totalChecked} total)`;
|
||||
|
||||
if (result.failureCount > 0 && result.successCount === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: message
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: message
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { logger } from '$logger/logger.ts';
|
||||
import { cleanupLogsJob } from './definitions/cleanupLogs.ts';
|
||||
import { createBackupJob } from './definitions/createBackup.ts';
|
||||
import { cleanupBackupsJob } from './definitions/cleanupBackups.ts';
|
||||
import { syncDatabasesJob } from './definitions/syncDatabases.ts';
|
||||
|
||||
/**
|
||||
* Register all job definitions
|
||||
@@ -15,6 +16,7 @@ function registerAllJobs(): void {
|
||||
jobRegistry.register(cleanupLogsJob);
|
||||
jobRegistry.register(createBackupJob);
|
||||
jobRegistry.register(cleanupBackupsJob);
|
||||
jobRegistry.register(syncDatabasesJob);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function createBackup(
|
||||
error: `Source path is not a directory: ${sourceDir}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Source directory does not exist: ${sourceDir}`,
|
||||
|
||||
139
src/lib/server/jobs/logic/syncDatabases.ts
Normal file
139
src/lib/server/jobs/logic/syncDatabases.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Core sync logic for PCD auto-sync
|
||||
* Checks for databases that need syncing and pulls updates if auto_pull is enabled
|
||||
*/
|
||||
|
||||
import { pcdManager } from '$pcd/pcd.ts';
|
||||
import { notificationManager } from '$notifications/NotificationManager.ts';
|
||||
|
||||
export interface DatabaseSyncStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
success: boolean;
|
||||
updatesPulled: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SyncDatabasesResult {
|
||||
totalChecked: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
databases: DatabaseSyncStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all databases that are due for auto-sync
|
||||
* Checks databases with sync_strategy > 0 that haven't been synced within their interval
|
||||
*/
|
||||
export async function syncDatabases(): Promise<SyncDatabasesResult> {
|
||||
const databases = pcdManager.getDueForSync();
|
||||
|
||||
const totalChecked = databases.length;
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
const statuses: DatabaseSyncStatus[] = [];
|
||||
|
||||
for (const db of databases) {
|
||||
try {
|
||||
// Check for updates
|
||||
const updateInfo = await pcdManager.checkForUpdates(db.id);
|
||||
|
||||
if (!updateInfo.hasUpdates) {
|
||||
// No updates available, just mark as checked
|
||||
statuses.push({
|
||||
id: db.id,
|
||||
name: db.name,
|
||||
success: true,
|
||||
updatesPulled: 0
|
||||
});
|
||||
successCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Updates are available
|
||||
if (db.auto_pull === 1) {
|
||||
// Auto-pull is enabled, sync the database
|
||||
const syncResult = await pcdManager.sync(db.id);
|
||||
|
||||
if (syncResult.success) {
|
||||
// Send success notification
|
||||
await notificationManager.notify({
|
||||
type: 'pcd.sync_success',
|
||||
title: 'Database Synced Successfully',
|
||||
message: `Database "${db.name}" has been updated (${syncResult.commitsBehind} commit${syncResult.commitsBehind === 1 ? '' : 's'} pulled)`,
|
||||
metadata: {
|
||||
databaseId: db.id,
|
||||
databaseName: db.name,
|
||||
commitsPulled: syncResult.commitsBehind
|
||||
}
|
||||
});
|
||||
|
||||
statuses.push({
|
||||
id: db.id,
|
||||
name: db.name,
|
||||
success: true,
|
||||
updatesPulled: syncResult.commitsBehind
|
||||
});
|
||||
successCount++;
|
||||
} else {
|
||||
// Send failure notification
|
||||
await notificationManager.notify({
|
||||
type: 'pcd.sync_failed',
|
||||
title: 'Database Sync Failed',
|
||||
message: `Failed to sync database "${db.name}": ${syncResult.error}`,
|
||||
metadata: {
|
||||
databaseId: db.id,
|
||||
databaseName: db.name,
|
||||
error: syncResult.error
|
||||
}
|
||||
});
|
||||
|
||||
statuses.push({
|
||||
id: db.id,
|
||||
name: db.name,
|
||||
success: false,
|
||||
updatesPulled: 0,
|
||||
error: syncResult.error
|
||||
});
|
||||
failureCount++;
|
||||
}
|
||||
} else {
|
||||
// Auto-pull is disabled, send notification
|
||||
await notificationManager.notify({
|
||||
type: 'pcd.updates_available',
|
||||
title: 'Database Updates Available',
|
||||
message: `Updates are available for database "${db.name}" (${updateInfo.commitsBehind} commit${updateInfo.commitsBehind === 1 ? '' : 's'} behind)`,
|
||||
metadata: {
|
||||
databaseId: db.id,
|
||||
databaseName: db.name,
|
||||
commitsBehind: updateInfo.commitsBehind
|
||||
}
|
||||
});
|
||||
|
||||
statuses.push({
|
||||
id: db.id,
|
||||
name: db.name,
|
||||
success: true,
|
||||
updatesPulled: 0
|
||||
});
|
||||
successCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
statuses.push({
|
||||
id: db.id,
|
||||
name: db.name,
|
||||
success: false,
|
||||
updatesPulled: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalChecked,
|
||||
successCount,
|
||||
failureCount,
|
||||
databases: statuses
|
||||
};
|
||||
}
|
||||
106
src/lib/server/pcd/deps.ts
Normal file
106
src/lib/server/pcd/deps.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* PCD Dependency Resolution
|
||||
* Handles cloning and managing PCD dependencies
|
||||
*/
|
||||
|
||||
import * as git from '$utils/git/git.ts';
|
||||
import { loadManifest } from './manifest.ts';
|
||||
|
||||
/**
|
||||
* Extract repository name from GitHub URL
|
||||
* https://github.com/Dictionarry-Hub/schema -> schema
|
||||
*/
|
||||
function getRepoName(repoUrl: string): string {
|
||||
const parts = repoUrl.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dependency path
|
||||
*/
|
||||
function getDependencyPath(pcdPath: string, repoName: string): string {
|
||||
return `${pcdPath}/deps/${repoName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone and checkout a single dependency
|
||||
*/
|
||||
async function cloneDependency(
|
||||
pcdPath: string,
|
||||
repoUrl: string,
|
||||
version: string
|
||||
): Promise<void> {
|
||||
const repoName = getRepoName(repoUrl);
|
||||
const depPath = getDependencyPath(pcdPath, repoName);
|
||||
|
||||
// Clone the dependency repository
|
||||
await git.clone(repoUrl, depPath);
|
||||
|
||||
// Checkout the specific version tag
|
||||
await git.checkout(depPath, version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all dependencies for a PCD
|
||||
* Clones dependencies and validates their manifests
|
||||
*/
|
||||
export async function processDependencies(pcdPath: string): Promise<void> {
|
||||
// Load the PCD's manifest
|
||||
const manifest = await loadManifest(pcdPath);
|
||||
|
||||
// Skip if no dependencies
|
||||
if (!manifest.dependencies || Object.keys(manifest.dependencies).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create deps directory
|
||||
const depsDir = `${pcdPath}/deps`;
|
||||
await Deno.mkdir(depsDir, { recursive: true });
|
||||
|
||||
// Process each dependency
|
||||
for (const [repoUrl, version] of Object.entries(manifest.dependencies)) {
|
||||
// Clone and checkout the dependency
|
||||
await cloneDependency(pcdPath, repoUrl, version);
|
||||
|
||||
// Validate the dependency's manifest
|
||||
const repoName = getRepoName(repoUrl);
|
||||
const depPath = getDependencyPath(pcdPath, repoName);
|
||||
await loadManifest(depPath);
|
||||
|
||||
// TODO (post-2.0): Recursively process nested dependencies
|
||||
// For now, we only support one level of dependencies
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all dependencies are present and valid
|
||||
*/
|
||||
export async function validateDependencies(pcdPath: string): Promise<boolean> {
|
||||
try {
|
||||
const manifest = await loadManifest(pcdPath);
|
||||
|
||||
// If no dependencies, validation passes
|
||||
if (!manifest.dependencies || Object.keys(manifest.dependencies).length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const [repoUrl] of Object.entries(manifest.dependencies)) {
|
||||
const repoName = getRepoName(repoUrl);
|
||||
const depPath = getDependencyPath(pcdPath, repoName);
|
||||
|
||||
// Check if dependency directory exists
|
||||
try {
|
||||
await Deno.stat(depPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate dependency manifest
|
||||
await loadManifest(depPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
152
src/lib/server/pcd/manifest.ts
Normal file
152
src/lib/server/pcd/manifest.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* PCD Manifest Parser and Validator
|
||||
* Handles reading and validating pcd.json files
|
||||
*/
|
||||
|
||||
export interface Manifest {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
dependencies?: Record<string, string>;
|
||||
arr_types?: string[];
|
||||
authors?: Array<{ name: string; email?: string }>;
|
||||
license?: string;
|
||||
repository?: string;
|
||||
tags?: string[];
|
||||
links?: {
|
||||
homepage?: string;
|
||||
documentation?: string;
|
||||
issues?: string;
|
||||
};
|
||||
profilarr: {
|
||||
minimum_version: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ManifestValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ManifestValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read manifest from a PCD repository
|
||||
*/
|
||||
export async function readManifest(pcdPath: string): Promise<Manifest> {
|
||||
const manifestPath = `${pcdPath}/pcd.json`;
|
||||
|
||||
try {
|
||||
const manifestContent = await Deno.readTextFile(manifestPath);
|
||||
const manifest = JSON.parse(manifestContent);
|
||||
return manifest;
|
||||
} catch (error) {
|
||||
if (error instanceof Deno.errors.NotFound) {
|
||||
throw new ManifestValidationError('pcd.json not found in repository');
|
||||
}
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new ManifestValidationError('pcd.json contains invalid JSON');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a manifest object
|
||||
*/
|
||||
export function validateManifest(manifest: unknown): asserts manifest is Manifest {
|
||||
if (!manifest || typeof manifest !== 'object') {
|
||||
throw new ManifestValidationError('Manifest must be an object');
|
||||
}
|
||||
|
||||
const m = manifest as Record<string, unknown>;
|
||||
|
||||
// Required fields
|
||||
if (typeof m.name !== 'string' || !m.name) {
|
||||
throw new ManifestValidationError('Manifest missing required field: name');
|
||||
}
|
||||
|
||||
if (typeof m.version !== 'string' || !m.version) {
|
||||
throw new ManifestValidationError('Manifest missing required field: version');
|
||||
}
|
||||
|
||||
if (typeof m.description !== 'string' || !m.description) {
|
||||
throw new ManifestValidationError('Manifest missing required field: description');
|
||||
}
|
||||
|
||||
// Validate dependencies if present
|
||||
if (m.dependencies !== undefined) {
|
||||
if (typeof m.dependencies !== 'object' || m.dependencies === null) {
|
||||
throw new ManifestValidationError('Manifest field dependencies must be an object');
|
||||
}
|
||||
|
||||
// Validate dependencies includes schema (only check for non-empty dependencies)
|
||||
const deps = m.dependencies as Record<string, unknown>;
|
||||
if (Object.keys(deps).length > 0) {
|
||||
const hasSchema = Object.keys(deps).some((url) => url.includes('/schema'));
|
||||
if (!hasSchema) {
|
||||
throw new ManifestValidationError('Manifest dependencies must include schema repository');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate profilarr section
|
||||
if (!m.profilarr || typeof m.profilarr !== 'object') {
|
||||
throw new ManifestValidationError('Manifest missing required field: profilarr');
|
||||
}
|
||||
|
||||
const profilarr = m.profilarr as Record<string, unknown>;
|
||||
if (typeof profilarr.minimum_version !== 'string' || !profilarr.minimum_version) {
|
||||
throw new ManifestValidationError('Manifest missing required field: profilarr.minimum_version');
|
||||
}
|
||||
|
||||
// Optional fields validation
|
||||
if (m.arr_types !== undefined) {
|
||||
if (!Array.isArray(m.arr_types)) {
|
||||
throw new ManifestValidationError('Manifest field arr_types must be an array');
|
||||
}
|
||||
const validTypes = ['radarr', 'sonarr', 'readarr', 'lidarr', 'prowlarr', 'whisparr'];
|
||||
for (const type of m.arr_types) {
|
||||
if (typeof type !== 'string' || !validTypes.includes(type)) {
|
||||
throw new ManifestValidationError(
|
||||
`Invalid arr_type: ${type}. Must be one of: ${validTypes.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (m.authors !== undefined) {
|
||||
if (!Array.isArray(m.authors)) {
|
||||
throw new ManifestValidationError('Manifest field authors must be an array');
|
||||
}
|
||||
for (const author of m.authors) {
|
||||
if (!author || typeof author !== 'object') {
|
||||
throw new ManifestValidationError('Each author must be an object');
|
||||
}
|
||||
const a = author as Record<string, unknown>;
|
||||
if (typeof a.name !== 'string' || !a.name) {
|
||||
throw new ManifestValidationError('Each author must have a name');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (m.tags !== undefined) {
|
||||
if (!Array.isArray(m.tags)) {
|
||||
throw new ManifestValidationError('Manifest field tags must be an array');
|
||||
}
|
||||
for (const tag of m.tags) {
|
||||
if (typeof tag !== 'string') {
|
||||
throw new ManifestValidationError('Each tag must be a string');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate manifest from a PCD repository
|
||||
*/
|
||||
export async function loadManifest(pcdPath: string): Promise<Manifest> {
|
||||
const manifest = await readManifest(pcdPath);
|
||||
validateManifest(manifest);
|
||||
return manifest;
|
||||
}
|
||||
19
src/lib/server/pcd/paths.ts
Normal file
19
src/lib/server/pcd/paths.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Helper functions for PCD paths
|
||||
*/
|
||||
|
||||
import { config } from '$config';
|
||||
|
||||
/**
|
||||
* Get the filesystem path for a PCD repository
|
||||
*/
|
||||
export function getPCDPath(uuid: string): string {
|
||||
return `${config.paths.databases}/${uuid}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the manifest file path for a PCD repository
|
||||
*/
|
||||
export function getManifestPath(uuid: string): string {
|
||||
return `${getPCDPath(uuid)}/pcd.json`;
|
||||
}
|
||||
242
src/lib/server/pcd/pcd.ts
Normal file
242
src/lib/server/pcd/pcd.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* PCD Manager - High-level orchestration for PCD lifecycle
|
||||
*/
|
||||
|
||||
import * as git from '$utils/git/git.ts';
|
||||
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
|
||||
import type { DatabaseInstance } from '$db/queries/databaseInstances.ts';
|
||||
import { loadManifest, type Manifest } from './manifest.ts';
|
||||
import { getPCDPath } from './paths.ts';
|
||||
import { processDependencies } from './deps.ts';
|
||||
import { notificationManager } from '$notifications/NotificationManager.ts';
|
||||
|
||||
export interface LinkOptions {
|
||||
repositoryUrl: string;
|
||||
name: string;
|
||||
branch?: string;
|
||||
syncStrategy?: number;
|
||||
autoPull?: boolean;
|
||||
personalAccessToken?: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
commitsBehind: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PCD Manager - Manages the lifecycle of Profilarr Compliant Databases
|
||||
*/
|
||||
class PCDManager {
|
||||
/**
|
||||
* Link a new PCD repository
|
||||
*/
|
||||
async link(options: LinkOptions): Promise<DatabaseInstance> {
|
||||
// Generate UUID for storage
|
||||
const uuid = crypto.randomUUID();
|
||||
const localPath = getPCDPath(uuid);
|
||||
|
||||
try {
|
||||
// Clone the repository and detect if it's private
|
||||
const isPrivate = await git.clone(options.repositoryUrl, localPath, options.branch, options.personalAccessToken);
|
||||
|
||||
// Validate manifest (loadManifest throws if invalid)
|
||||
await loadManifest(localPath);
|
||||
|
||||
// Process dependencies (clone and validate)
|
||||
await processDependencies(localPath);
|
||||
|
||||
// Insert into database
|
||||
const id = databaseInstancesQueries.create({
|
||||
uuid,
|
||||
name: options.name,
|
||||
repositoryUrl: options.repositoryUrl,
|
||||
localPath,
|
||||
syncStrategy: options.syncStrategy,
|
||||
autoPull: options.autoPull,
|
||||
personalAccessToken: options.personalAccessToken,
|
||||
isPrivate
|
||||
});
|
||||
|
||||
// Get and return the created instance
|
||||
const instance = databaseInstancesQueries.getById(id);
|
||||
if (!instance) {
|
||||
throw new Error('Failed to retrieve created database instance');
|
||||
}
|
||||
|
||||
// Send notification
|
||||
await notificationManager.notify({
|
||||
type: 'pcd.linked',
|
||||
title: 'Database Linked',
|
||||
message: `Database "${options.name}" has been linked successfully`,
|
||||
metadata: {
|
||||
databaseId: id,
|
||||
databaseName: options.name,
|
||||
repositoryUrl: options.repositoryUrl
|
||||
}
|
||||
});
|
||||
|
||||
return instance;
|
||||
} catch (error) {
|
||||
// Cleanup on failure - remove cloned directory
|
||||
try {
|
||||
await Deno.remove(localPath, { recursive: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a PCD repository
|
||||
*/
|
||||
async unlink(id: number): Promise<void> {
|
||||
const instance = databaseInstancesQueries.getById(id);
|
||||
if (!instance) {
|
||||
throw new Error(`Database instance ${id} not found`);
|
||||
}
|
||||
|
||||
// Store name and URL for notification
|
||||
const { name, repository_url } = instance;
|
||||
|
||||
// Delete from database first
|
||||
databaseInstancesQueries.delete(id);
|
||||
|
||||
// Then cleanup filesystem
|
||||
try {
|
||||
await Deno.remove(instance.local_path, { recursive: true });
|
||||
} catch (error) {
|
||||
// Log but don't throw - database entry is already deleted
|
||||
console.error(`Failed to remove PCD directory ${instance.local_path}:`, error);
|
||||
}
|
||||
|
||||
// Send notification
|
||||
await notificationManager.notify({
|
||||
type: 'pcd.unlinked',
|
||||
title: 'Database Unlinked',
|
||||
message: `Database "${name}" has been removed`,
|
||||
metadata: {
|
||||
databaseId: id,
|
||||
databaseName: name,
|
||||
repositoryUrl: repository_url
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a PCD repository (pull updates)
|
||||
*/
|
||||
async sync(id: number): Promise<SyncResult> {
|
||||
const instance = databaseInstancesQueries.getById(id);
|
||||
if (!instance) {
|
||||
throw new Error(`Database instance ${id} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for updates first
|
||||
const updateInfo = await git.checkForUpdates(instance.local_path);
|
||||
|
||||
if (!updateInfo.hasUpdates) {
|
||||
// Already up to date
|
||||
databaseInstancesQueries.updateSyncedAt(id);
|
||||
return {
|
||||
success: true,
|
||||
commitsBehind: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Pull updates
|
||||
await git.pull(instance.local_path);
|
||||
|
||||
// Update last_synced_at
|
||||
databaseInstancesQueries.updateSyncedAt(id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commitsBehind: updateInfo.commitsBehind
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
commitsBehind: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for available updates without pulling
|
||||
*/
|
||||
async checkForUpdates(id: number): Promise<git.UpdateInfo> {
|
||||
const instance = databaseInstancesQueries.getById(id);
|
||||
if (!instance) {
|
||||
throw new Error(`Database instance ${id} not found`);
|
||||
}
|
||||
|
||||
return await git.checkForUpdates(instance.local_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parsed manifest for a PCD
|
||||
*/
|
||||
async getManifest(id: number): Promise<Manifest> {
|
||||
const instance = databaseInstancesQueries.getById(id);
|
||||
if (!instance) {
|
||||
throw new Error(`Database instance ${id} not found`);
|
||||
}
|
||||
|
||||
return await loadManifest(instance.local_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch branch for a PCD
|
||||
*/
|
||||
async switchBranch(id: number, branch: string): Promise<void> {
|
||||
const instance = databaseInstancesQueries.getById(id);
|
||||
if (!instance) {
|
||||
throw new Error(`Database instance ${id} not found`);
|
||||
}
|
||||
|
||||
await git.checkout(instance.local_path, branch);
|
||||
await git.pull(instance.local_path);
|
||||
databaseInstancesQueries.updateSyncedAt(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git status for a PCD
|
||||
*/
|
||||
async getStatus(id: number): Promise<git.GitStatus> {
|
||||
const instance = databaseInstancesQueries.getById(id);
|
||||
if (!instance) {
|
||||
throw new Error(`Database instance ${id} not found`);
|
||||
}
|
||||
|
||||
return await git.getStatus(instance.local_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all PCDs
|
||||
*/
|
||||
getAll(): DatabaseInstance[] {
|
||||
return databaseInstancesQueries.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PCD by ID
|
||||
*/
|
||||
getById(id: number): DatabaseInstance | undefined {
|
||||
return databaseInstancesQueries.getById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PCDs that need auto-sync
|
||||
*/
|
||||
getDueForSync(): DatabaseInstance[] {
|
||||
return databaseInstancesQueries.getDueForSync();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pcdManager = new PCDManager();
|
||||
@@ -26,6 +26,7 @@ class Config {
|
||||
await Deno.mkdir(this.paths.logs, { recursive: true });
|
||||
await Deno.mkdir(this.paths.data, { recursive: true });
|
||||
await Deno.mkdir(this.paths.backups, { recursive: true });
|
||||
await Deno.mkdir(this.paths.databases, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +55,9 @@ class Config {
|
||||
get database(): string {
|
||||
return `${config.basePath}/data/profilarr.db`;
|
||||
},
|
||||
get databases(): string {
|
||||
return `${config.basePath}/data/databases`;
|
||||
},
|
||||
get backups(): string {
|
||||
return `${config.basePath}/backups`;
|
||||
}
|
||||
|
||||
299
src/lib/server/utils/git/git.ts
Normal file
299
src/lib/server/utils/git/git.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Git utility functions for managing database repositories
|
||||
*/
|
||||
|
||||
export interface GitStatus {
|
||||
currentBranch: string;
|
||||
isDirty: boolean;
|
||||
untracked: string[];
|
||||
modified: string[];
|
||||
staged: string[];
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
hasUpdates: boolean;
|
||||
commitsBehind: number;
|
||||
commitsAhead: number;
|
||||
latestRemoteCommit: string;
|
||||
currentLocalCommit: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a git command with sandboxed environment (no system credentials)
|
||||
*/
|
||||
async function execGit(args: string[], cwd?: string): Promise<string> {
|
||||
const command = new Deno.Command('git', {
|
||||
args,
|
||||
cwd,
|
||||
stdout: 'piped',
|
||||
stderr: 'piped',
|
||||
env: {
|
||||
// Disable all credential helpers and interactive prompts
|
||||
GIT_TERMINAL_PROMPT: '0', // Fail instead of prompting (git 2.3+)
|
||||
GIT_ASKPASS: 'echo', // Return empty on credential requests
|
||||
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', // Disable SSH password prompts
|
||||
// Clear credential helpers via environment config
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'credential.helper',
|
||||
GIT_CONFIG_VALUE_0: ''
|
||||
}
|
||||
});
|
||||
|
||||
const { code, stdout, stderr } = await command.output();
|
||||
|
||||
if (code !== 0) {
|
||||
const errorMessage = new TextDecoder().decode(stderr);
|
||||
throw new Error(`Git command failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(stdout).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a repository URL is accessible and detect if it's private using GitHub API
|
||||
* Returns true if the repository is private, false if public
|
||||
*/
|
||||
async function validateRepository(repositoryUrl: string, personalAccessToken?: string): Promise<boolean> {
|
||||
// Validate GitHub URL format and extract owner/repo
|
||||
const githubPattern = /^https:\/\/github\.com\/([\w-]+)\/([\w-]+)\/?$/;
|
||||
const normalizedUrl = repositoryUrl.replace(/\.git$/, '');
|
||||
const match = normalizedUrl.match(githubPattern);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Repository URL must be a valid GitHub repository (https://github.com/username/repo)');
|
||||
}
|
||||
|
||||
const [, owner, repo] = match;
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
|
||||
|
||||
// First try without authentication to check if it's public
|
||||
try {
|
||||
const response = await globalThis.fetch(apiUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'Profilarr'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Repository is accessible without auth
|
||||
return data.private === true;
|
||||
}
|
||||
|
||||
// 404 or 403 means repo doesn't exist or is private
|
||||
if (response.status === 404 || response.status === 403) {
|
||||
// If we have a PAT, try with authentication
|
||||
if (personalAccessToken) {
|
||||
const authResponse = await globalThis.fetch(apiUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'Authorization': `Bearer ${personalAccessToken}`,
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'Profilarr'
|
||||
}
|
||||
});
|
||||
|
||||
if (authResponse.ok) {
|
||||
const data = await authResponse.json();
|
||||
return data.private === true;
|
||||
}
|
||||
|
||||
if (authResponse.status === 404) {
|
||||
throw new Error('Repository not found. Please check the URL.');
|
||||
}
|
||||
|
||||
if (authResponse.status === 401 || authResponse.status === 403) {
|
||||
throw new Error('Unable to access repository. Please check your Personal Access Token has the correct permissions (repo scope required).');
|
||||
}
|
||||
|
||||
throw new Error(`GitHub API error: ${authResponse.status} ${authResponse.statusText}`);
|
||||
}
|
||||
|
||||
throw new Error('Repository not found or is private. Please provide a Personal Access Token if this is a private repository.');
|
||||
}
|
||||
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (
|
||||
error.message.includes('Repository not found') ||
|
||||
error.message.includes('Unable to access') ||
|
||||
error.message.includes('GitHub API error')
|
||||
)) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to validate repository: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a git repository
|
||||
* Returns true if the repository is private, false if public
|
||||
*/
|
||||
export async function clone(
|
||||
repositoryUrl: string,
|
||||
targetPath: string,
|
||||
branch?: string,
|
||||
personalAccessToken?: string
|
||||
): Promise<boolean> {
|
||||
// Validate repository exists and detect if it's private
|
||||
const isPrivate = await validateRepository(repositoryUrl, personalAccessToken);
|
||||
|
||||
const args = ['clone'];
|
||||
|
||||
if (branch) {
|
||||
args.push('--branch', branch);
|
||||
}
|
||||
|
||||
// Inject personal access token into URL if provided (for private repos or push access)
|
||||
let authUrl = repositoryUrl;
|
||||
if (personalAccessToken) {
|
||||
// Format: https://TOKEN@github.com/username/repo
|
||||
authUrl = repositoryUrl.replace('https://github.com', `https://${personalAccessToken}@github.com`);
|
||||
}
|
||||
|
||||
args.push(authUrl, targetPath);
|
||||
|
||||
await execGit(args);
|
||||
|
||||
return isPrivate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull latest changes from remote
|
||||
*/
|
||||
export async function pull(repoPath: string): Promise<void> {
|
||||
await execGit(['pull'], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from remote without merging
|
||||
*/
|
||||
export async function fetchRemote(repoPath: string): Promise<void> {
|
||||
await execGit(['fetch'], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current branch name
|
||||
*/
|
||||
export async function getCurrentBranch(repoPath: string): Promise<string> {
|
||||
return await execGit(['branch', '--show-current'], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout a branch
|
||||
*/
|
||||
export async function checkout(repoPath: string, branch: string): Promise<void> {
|
||||
await execGit(['checkout', branch], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository status
|
||||
*/
|
||||
export async function getStatus(repoPath: string): Promise<GitStatus> {
|
||||
const currentBranch = await getCurrentBranch(repoPath);
|
||||
|
||||
// Get short status
|
||||
const statusOutput = await execGit(['status', '--short'], repoPath);
|
||||
|
||||
const untracked: string[] = [];
|
||||
const modified: string[] = [];
|
||||
const staged: string[] = [];
|
||||
|
||||
for (const line of statusOutput.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const status = line.substring(0, 2);
|
||||
const file = line.substring(3);
|
||||
|
||||
if (status.startsWith('??')) {
|
||||
untracked.push(file);
|
||||
} else if (status[1] === 'M' || status[1] === 'D') {
|
||||
modified.push(file);
|
||||
} else if (status[0] === 'M' || status[0] === 'A' || status[0] === 'D') {
|
||||
staged.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
const isDirty = untracked.length > 0 || modified.length > 0 || staged.length > 0;
|
||||
|
||||
return {
|
||||
currentBranch,
|
||||
isDirty,
|
||||
untracked,
|
||||
modified,
|
||||
staged
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates from remote
|
||||
*/
|
||||
export async function checkForUpdates(repoPath: string): Promise<UpdateInfo> {
|
||||
// Fetch latest from remote
|
||||
await fetchRemote(repoPath);
|
||||
|
||||
const currentBranch = await getCurrentBranch(repoPath);
|
||||
const remoteBranch = `origin/${currentBranch}`;
|
||||
|
||||
// Get current commit
|
||||
const currentLocalCommit = await execGit(['rev-parse', 'HEAD'], repoPath);
|
||||
|
||||
// Get remote commit
|
||||
let latestRemoteCommit: string;
|
||||
try {
|
||||
latestRemoteCommit = await execGit(['rev-parse', remoteBranch], repoPath);
|
||||
} catch {
|
||||
// Remote branch doesn't exist or hasn't been fetched
|
||||
return {
|
||||
hasUpdates: false,
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
latestRemoteCommit: currentLocalCommit,
|
||||
currentLocalCommit
|
||||
};
|
||||
}
|
||||
|
||||
// Count commits behind
|
||||
let commitsBehind = 0;
|
||||
try {
|
||||
const behindOutput = await execGit(
|
||||
['rev-list', '--count', `HEAD..${remoteBranch}`],
|
||||
repoPath
|
||||
);
|
||||
commitsBehind = parseInt(behindOutput) || 0;
|
||||
} catch {
|
||||
commitsBehind = 0;
|
||||
}
|
||||
|
||||
// Count commits ahead
|
||||
let commitsAhead = 0;
|
||||
try {
|
||||
const aheadOutput = await execGit(
|
||||
['rev-list', '--count', `${remoteBranch}..HEAD`],
|
||||
repoPath
|
||||
);
|
||||
commitsAhead = parseInt(aheadOutput) || 0;
|
||||
} catch {
|
||||
commitsAhead = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
hasUpdates: commitsBehind > 0,
|
||||
commitsBehind,
|
||||
commitsAhead,
|
||||
latestRemoteCommit,
|
||||
currentLocalCommit
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset repository to match remote (discards local changes)
|
||||
*/
|
||||
export async function resetToRemote(repoPath: string): Promise<void> {
|
||||
const currentBranch = await getCurrentBranch(repoPath);
|
||||
const remoteBranch = `origin/${currentBranch}`;
|
||||
|
||||
await execGit(['reset', '--hard', remoteBranch], repoPath);
|
||||
}
|
||||
@@ -54,21 +54,24 @@ class Logger {
|
||||
* Check if logging is enabled
|
||||
*/
|
||||
private isEnabled(): boolean {
|
||||
return this.config.enabled;
|
||||
const currentSettings = logSettings.get();
|
||||
return currentSettings.enabled === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file logging is enabled
|
||||
*/
|
||||
private isFileLoggingEnabled(): boolean {
|
||||
return this.config.fileLogging;
|
||||
const currentSettings = logSettings.get();
|
||||
return currentSettings.file_logging === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if console logging is enabled
|
||||
*/
|
||||
private isConsoleLoggingEnabled(): boolean {
|
||||
return this.config.consoleLogging;
|
||||
const currentSettings = logSettings.get();
|
||||
return currentSettings.console_logging === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,8 +82,12 @@ class Logger {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get fresh settings from database instead of using cached config
|
||||
const currentSettings = logSettings.get();
|
||||
const currentMinLevel = currentSettings.min_level;
|
||||
|
||||
const levels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
|
||||
const minIndex = levels.indexOf(this.config.minLevel);
|
||||
const minIndex = levels.indexOf(currentMinLevel);
|
||||
const levelIndex = levels.indexOf(level);
|
||||
|
||||
return levelIndex >= minIndex;
|
||||
|
||||
118
src/lib/shared/notificationTypes.ts
Normal file
118
src/lib/shared/notificationTypes.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Shared notification types for both backend and frontend
|
||||
* Defines all available notification types and their metadata
|
||||
*/
|
||||
|
||||
export interface NotificationType {
|
||||
id: string;
|
||||
label: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* All available notification types
|
||||
*/
|
||||
export const notificationTypes: NotificationType[] = [
|
||||
// Backups
|
||||
{
|
||||
id: 'job.create_backup.success',
|
||||
label: 'Backup Created (Success)',
|
||||
category: 'Backups',
|
||||
description: 'Notification when a backup is created successfully'
|
||||
},
|
||||
{
|
||||
id: 'job.create_backup.failed',
|
||||
label: 'Backup Created (Failed)',
|
||||
category: 'Backups',
|
||||
description: 'Notification when backup creation fails'
|
||||
},
|
||||
{
|
||||
id: 'job.cleanup_backups.success',
|
||||
label: 'Backup Cleanup (Success)',
|
||||
category: 'Backups',
|
||||
description: 'Notification when old backups are cleaned up successfully'
|
||||
},
|
||||
{
|
||||
id: 'job.cleanup_backups.failed',
|
||||
label: 'Backup Cleanup (Failed)',
|
||||
category: 'Backups',
|
||||
description: 'Notification when backup cleanup fails'
|
||||
},
|
||||
|
||||
// Logs
|
||||
{
|
||||
id: 'job.cleanup_logs.success',
|
||||
label: 'Log Cleanup (Success)',
|
||||
category: 'Logs',
|
||||
description: 'Notification when old logs are cleaned up successfully'
|
||||
},
|
||||
{
|
||||
id: 'job.cleanup_logs.failed',
|
||||
label: 'Log Cleanup (Failed)',
|
||||
category: 'Logs',
|
||||
description: 'Notification when log cleanup fails'
|
||||
},
|
||||
|
||||
// Database Sync
|
||||
{
|
||||
id: 'pcd.linked',
|
||||
label: 'Database Linked',
|
||||
category: 'Databases',
|
||||
description: 'Notification when a new database is linked'
|
||||
},
|
||||
{
|
||||
id: 'pcd.unlinked',
|
||||
label: 'Database Unlinked',
|
||||
category: 'Databases',
|
||||
description: 'Notification when a database is removed'
|
||||
},
|
||||
{
|
||||
id: 'pcd.updates_available',
|
||||
label: 'Database Updates Available',
|
||||
category: 'Databases',
|
||||
description: 'Notification when database updates are available but auto-pull is disabled'
|
||||
},
|
||||
{
|
||||
id: 'pcd.sync_success',
|
||||
label: 'Database Synced (Success)',
|
||||
category: 'Databases',
|
||||
description: 'Notification when a database is synced successfully'
|
||||
},
|
||||
{
|
||||
id: 'pcd.sync_failed',
|
||||
label: 'Database Sync (Failed)',
|
||||
category: 'Databases',
|
||||
description: 'Notification when database sync fails'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Group notification types by category
|
||||
*/
|
||||
export function groupNotificationTypesByCategory(): Record<string, NotificationType[]> {
|
||||
return notificationTypes.reduce(
|
||||
(acc, type) => {
|
||||
if (!acc[type.category]) {
|
||||
acc[type.category] = [];
|
||||
}
|
||||
acc[type.category].push(type);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, NotificationType[]>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notification type IDs
|
||||
*/
|
||||
export function getAllNotificationTypeIds(): string[] {
|
||||
return notificationTypes.map((type) => type.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a notification type ID exists
|
||||
*/
|
||||
export function isValidNotificationType(typeId: string): boolean {
|
||||
return notificationTypes.some((type) => type.id === typeId);
|
||||
}
|
||||
54
src/routes/databases/+page.server.ts
Normal file
54
src/routes/databases/+page.server.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, ServerLoad } from '@sveltejs/kit';
|
||||
import { pcdManager } from '$pcd/pcd.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const load: ServerLoad = () => {
|
||||
const databases = pcdManager.getAll();
|
||||
|
||||
return {
|
||||
databases
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
delete: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const id = parseInt(formData.get('id')?.toString() || '0', 10);
|
||||
|
||||
if (!id) {
|
||||
await logger.warn('Attempted to unlink database without ID', {
|
||||
source: 'databases'
|
||||
});
|
||||
|
||||
return fail(400, {
|
||||
error: 'Database ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await pcdManager.unlink(id);
|
||||
|
||||
await logger.info(`Unlinked database: ${id}`, {
|
||||
source: 'databases',
|
||||
meta: { id }
|
||||
});
|
||||
|
||||
redirect(303, '/databases');
|
||||
} catch (error) {
|
||||
// Re-throw redirect errors (they're not actual errors)
|
||||
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await logger.error('Failed to unlink database', {
|
||||
source: 'databases',
|
||||
meta: { error: error instanceof Error ? error.message : String(error) }
|
||||
});
|
||||
|
||||
return fail(500, {
|
||||
error: error instanceof Error ? error.message : 'Failed to unlink database'
|
||||
});
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
223
src/routes/databases/+page.svelte
Normal file
223
src/routes/databases/+page.svelte
Normal file
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
import { Database, Plus, Lock, Code, Trash2, Pencil, ExternalLink } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import EmptyState from '$ui/state/EmptyState.svelte';
|
||||
import Table from '$ui/table/Table.svelte';
|
||||
import Modal from '$ui/modal/Modal.svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import type { PageData } from './$types';
|
||||
import type { Column } from '$ui/table/Table.svelte';
|
||||
import type { DatabaseInstance } from '$db/queries/databaseInstances.ts';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
// Modal state
|
||||
let showUnlinkModal = false;
|
||||
let selectedDatabase: DatabaseInstance | null = null;
|
||||
let unlinkFormElement: HTMLFormElement;
|
||||
|
||||
// Extract GitHub username/org from repository URL
|
||||
function getGitHubAvatar(repoUrl: string): string {
|
||||
const match = repoUrl.match(/github\.com\/([^\/]+)\//);
|
||||
if (match) {
|
||||
return `https://github.com/${match[1]}.png?size=40`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Format sync strategy for display
|
||||
function formatSyncStrategy(minutes: number): string {
|
||||
if (minutes === 0) return 'Manual';
|
||||
if (minutes < 60) return `Every ${minutes} min`;
|
||||
if (minutes === 60) return 'Hourly';
|
||||
if (minutes < 1440) return `Every ${minutes / 60}h`;
|
||||
return `Every ${minutes / 1440}d`;
|
||||
}
|
||||
|
||||
// Format last synced date
|
||||
function formatLastSynced(date: string | null): string {
|
||||
if (!date) return 'Never';
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Handle row click
|
||||
function handleRowClick(database: DatabaseInstance) {
|
||||
goto(`/databases/${database.id}`);
|
||||
}
|
||||
|
||||
// Handle unlink click
|
||||
function handleUnlinkClick(e: MouseEvent, database: DatabaseInstance) {
|
||||
e.stopPropagation(); // Prevent row click
|
||||
selectedDatabase = database;
|
||||
showUnlinkModal = true;
|
||||
}
|
||||
|
||||
// Define table columns
|
||||
const columns: Column<DatabaseInstance>[] = [
|
||||
{ key: 'name', header: 'Name', align: 'left' },
|
||||
{ key: 'repository_url', header: 'Repository', align: 'left' },
|
||||
{ key: 'sync_strategy', header: 'Sync', align: 'left', width: 'w-32' },
|
||||
{ key: 'last_synced_at', header: 'Last Synced', align: 'left', width: 'w-40' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Databases - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if data.databases.length === 0}
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title="No Databases Linked"
|
||||
description="Link a Profilarr Compliant Database to get started with profile management."
|
||||
buttonText="Link Database"
|
||||
buttonHref="/databases/new"
|
||||
buttonIcon={Plus}
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-6 p-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">Databases</h1>
|
||||
<p class="mt-1 text-neutral-600 dark:text-neutral-400">
|
||||
Manage your linked Profilarr Compliant Databases
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/databases/new"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Link Database
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Database Table -->
|
||||
<Table {columns} data={data.databases} hoverable={true}>
|
||||
<svelte:fragment slot="cell" let:row let:column>
|
||||
<div on:click={() => handleRowClick(row)} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && handleRowClick(row)} class="cursor-pointer">
|
||||
{#if column.key === 'name'}
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
src={getGitHubAvatar(row.repository_url)}
|
||||
alt="{row.name} avatar"
|
||||
class="h-8 w-8 rounded-lg"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-50">
|
||||
{row.name}
|
||||
</div>
|
||||
{#if row.is_private}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
<Lock size={10} />
|
||||
Private
|
||||
</span>
|
||||
{/if}
|
||||
{#if row.personal_access_token}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
<Code size={10} />
|
||||
Dev
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if column.key === 'repository_url'}
|
||||
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
{row.repository_url.replace('https://github.com/', '')}
|
||||
</code>
|
||||
{:else if column.key === 'sync_strategy'}
|
||||
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
{formatSyncStrategy(row.sync_strategy)}
|
||||
</code>
|
||||
{:else if column.key === 'last_synced_at'}
|
||||
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
{formatLastSynced(row.last_synced_at)}
|
||||
</code>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="actions" let:row>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<!-- GitHub Link Button -->
|
||||
<a
|
||||
href={row.repository_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-blue-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-blue-400 dark:hover:bg-neutral-700"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
|
||||
<!-- Edit Button -->
|
||||
<a
|
||||
href="/databases/{row.id}/edit"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
|
||||
title="Edit database"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</a>
|
||||
|
||||
<!-- Unlink Button -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={(e) => handleUnlinkClick(e, row)}
|
||||
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-red-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-neutral-700"
|
||||
title="Unlink database"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Unlink Confirmation Modal -->
|
||||
<Modal
|
||||
open={showUnlinkModal}
|
||||
header="Unlink Database"
|
||||
bodyMessage={`Are you sure you want to unlink "${selectedDatabase?.name}"? This action cannot be undone and all local data will be permanently removed.`}
|
||||
confirmText="Unlink"
|
||||
cancelText="Cancel"
|
||||
confirmDanger={true}
|
||||
on:confirm={() => {
|
||||
showUnlinkModal = false;
|
||||
if (selectedDatabase) {
|
||||
unlinkFormElement?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
on:cancel={() => {
|
||||
showUnlinkModal = false;
|
||||
selectedDatabase = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Hidden unlink form -->
|
||||
<form
|
||||
bind:this={unlinkFormElement}
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
class="hidden"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure' && result.data) {
|
||||
alertStore.add(
|
||||
'error',
|
||||
(result.data as { error?: string }).error || 'Failed to unlink database'
|
||||
);
|
||||
} else if (result.type === 'redirect') {
|
||||
alertStore.add('success', 'Database unlinked successfully');
|
||||
}
|
||||
await update();
|
||||
selectedDatabase = null;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={selectedDatabase?.id || ''} />
|
||||
</form>
|
||||
21
src/routes/databases/[id]/+page.server.ts
Normal file
21
src/routes/databases/[id]/+page.server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
|
||||
|
||||
export const load: ServerLoad = ({ params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
|
||||
if (isNaN(id)) {
|
||||
error(400, 'Invalid database ID');
|
||||
}
|
||||
|
||||
const database = databaseInstancesQueries.getById(id);
|
||||
|
||||
if (!database) {
|
||||
error(404, 'Database not found');
|
||||
}
|
||||
|
||||
return {
|
||||
database
|
||||
};
|
||||
};
|
||||
19
src/routes/databases/[id]/+page.svelte
Normal file
19
src/routes/databases/[id]/+page.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { Pencil } from 'lucide-svelte';
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.database.name} - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Floating Edit Button -->
|
||||
<a
|
||||
href="/databases/{data.database.id}/edit"
|
||||
class="group fixed right-8 bottom-8 z-50 flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-700 shadow-md transition-all hover:scale-110 hover:border-neutral-300 hover:shadow-lg dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:border-neutral-600"
|
||||
aria-label="Edit database"
|
||||
>
|
||||
<Pencil size={18} class="transition-transform duration-300 group-hover:rotate-12" />
|
||||
</a>
|
||||
172
src/routes/databases/[id]/edit/+page.server.ts
Normal file
172
src/routes/databases/[id]/edit/+page.server.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { error, redirect, fail } from '@sveltejs/kit';
|
||||
import type { ServerLoad, Actions } from '@sveltejs/kit';
|
||||
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
|
||||
import { pcdManager } from '$pcd/pcd.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const load: ServerLoad = ({ params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
|
||||
// Validate ID
|
||||
if (isNaN(id)) {
|
||||
error(404, `Invalid database ID: ${params.id}`);
|
||||
}
|
||||
|
||||
// Fetch the specific instance
|
||||
const instance = databaseInstancesQueries.getById(id);
|
||||
|
||||
if (!instance) {
|
||||
error(404, `Database not found: ${id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
instance
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ params, request }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
|
||||
// Validate ID
|
||||
if (isNaN(id)) {
|
||||
await logger.warn('Update failed: Invalid database ID', {
|
||||
source: 'databases/[id]/edit',
|
||||
meta: { id: params.id }
|
||||
});
|
||||
return fail(400, { error: 'Invalid database ID' });
|
||||
}
|
||||
|
||||
// Fetch the instance to verify it exists
|
||||
const instance = databaseInstancesQueries.getById(id);
|
||||
|
||||
if (!instance) {
|
||||
await logger.warn('Update failed: Database not found', {
|
||||
source: 'databases/[id]/edit',
|
||||
meta: { id }
|
||||
});
|
||||
return fail(404, { error: 'Database not found' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const name = formData.get('name')?.toString().trim();
|
||||
const syncStrategy = parseInt(formData.get('sync_strategy')?.toString() || '0', 10);
|
||||
const autoPull = formData.get('auto_pull') === '1';
|
||||
|
||||
// Validation
|
||||
if (!name) {
|
||||
await logger.warn('Attempted to update database with missing required fields', {
|
||||
source: 'databases/[id]/edit',
|
||||
meta: { id, name }
|
||||
});
|
||||
|
||||
return fail(400, {
|
||||
error: 'Name is required',
|
||||
values: { name }
|
||||
});
|
||||
}
|
||||
|
||||
// Check if name already exists (excluding current instance)
|
||||
const existingWithName = databaseInstancesQueries.getAll().find(
|
||||
(db) => db.name === name && db.id !== id
|
||||
);
|
||||
|
||||
if (existingWithName) {
|
||||
await logger.warn('Attempted to update database with duplicate name', {
|
||||
source: 'databases/[id]/edit',
|
||||
meta: { id, name }
|
||||
});
|
||||
|
||||
return fail(400, {
|
||||
error: 'A database with this name already exists',
|
||||
values: { name }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Update the database
|
||||
const updated = databaseInstancesQueries.update(id, {
|
||||
name,
|
||||
syncStrategy,
|
||||
autoPull
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new Error('Update returned false');
|
||||
}
|
||||
|
||||
await logger.info(`Updated database: ${name}`, {
|
||||
source: 'databases/[id]/edit',
|
||||
meta: { id, name }
|
||||
});
|
||||
|
||||
// Redirect to database detail page
|
||||
redirect(303, `/databases/${id}`);
|
||||
} catch (error) {
|
||||
// Re-throw redirect errors (they're not actual errors)
|
||||
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await logger.error('Failed to update database', {
|
||||
source: 'databases/[id]/edit',
|
||||
meta: { error: error instanceof Error ? error.message : String(error) }
|
||||
});
|
||||
|
||||
return fail(500, {
|
||||
error: 'Failed to update database',
|
||||
values: { name }
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
|
||||
// Validate ID
|
||||
if (isNaN(id)) {
|
||||
await logger.warn('Delete failed: Invalid database ID', {
|
||||
source: 'databases/[id]/edit',
|
||||
meta: { id: params.id }
|
||||
});
|
||||
return fail(400, { error: 'Invalid database ID' });
|
||||
}
|
||||
|
||||
// Fetch the instance to verify it exists
|
||||
const instance = databaseInstancesQueries.getById(id);
|
||||
|
||||
if (!instance) {
|
||||
await logger.warn('Delete failed: Database not found', {
|
||||
source: 'databases/[id]/edit',
|
||||
meta: { id }
|
||||
});
|
||||
return fail(404, { error: 'Database not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Unlink the database
|
||||
await pcdManager.unlink(id);
|
||||
|
||||
await logger.info(`Unlinked database: ${instance.name}`, {
|
||||
source: 'databases/[id]/edit',
|
||||
meta: { id, name: instance.name, repositoryUrl: instance.repository_url }
|
||||
});
|
||||
|
||||
// Redirect to databases list
|
||||
redirect(303, '/databases');
|
||||
} catch (error) {
|
||||
// Re-throw redirect errors (they're not actual errors)
|
||||
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await logger.error('Failed to unlink database', {
|
||||
source: 'databases/[id]/edit',
|
||||
meta: { error: error instanceof Error ? error.message : String(error) }
|
||||
});
|
||||
|
||||
return fail(500, { error: 'Failed to unlink database' });
|
||||
}
|
||||
}
|
||||
};
|
||||
9
src/routes/databases/[id]/edit/+page.svelte
Normal file
9
src/routes/databases/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import InstanceForm from '../../components/InstanceForm.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
export let form: ActionData;
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<InstanceForm mode="edit" {form} instance={data.instance} />
|
||||
21
src/routes/databases/bruh/+page.server.ts
Normal file
21
src/routes/databases/bruh/+page.server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
|
||||
export const load: ServerLoad = ({ url }) => {
|
||||
const urlParam = url.searchParams.get('url') || '';
|
||||
const type = url.searchParams.get('type') || 'unknown';
|
||||
const name = url.searchParams.get('name') || '';
|
||||
const branch = url.searchParams.get('branch') || '';
|
||||
const syncStrategy = url.searchParams.get('sync_strategy') || '';
|
||||
const autoPull = url.searchParams.get('auto_pull') || '';
|
||||
|
||||
return {
|
||||
url: urlParam,
|
||||
type,
|
||||
formData: {
|
||||
name,
|
||||
branch,
|
||||
syncStrategy,
|
||||
autoPull
|
||||
}
|
||||
};
|
||||
};
|
||||
110
src/routes/databases/bruh/+page.svelte
Normal file
110
src/routes/databases/bruh/+page.svelte
Normal file
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { RotateCcw } from 'lucide-svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
onMount(() => {
|
||||
alertStore.add('error', 'bruh', 8000);
|
||||
});
|
||||
|
||||
// Extract YouTube video ID from URL and add autoplay
|
||||
function getYouTubeEmbedUrl(url: string): string {
|
||||
const videoIdMatch = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\n?#]+)/);
|
||||
if (videoIdMatch) {
|
||||
return `https://www.youtube.com/embed/${videoIdMatch[1]}?autoplay=1`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
$: embedUrl = data.type === 'youtube' ? getYouTubeEmbedUrl(data.url) : '';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Bruh - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center bg-white p-8 dark:bg-neutral-950">
|
||||
<div class="mx-auto w-full max-w-4xl space-y-6">
|
||||
<!-- Cheeky Message -->
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-medium text-neutral-900 dark:text-neutral-50">
|
||||
If you insist...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Embed Container -->
|
||||
<div class="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-800 dark:bg-neutral-900">
|
||||
{#if data.type === 'youtube' && embedUrl}
|
||||
<div class="aspect-video w-full">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title="YouTube video"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
class="h-full w-full"
|
||||
></iframe>
|
||||
</div>
|
||||
{:else if data.type === 'twitter'}
|
||||
<div class="p-12 text-center">
|
||||
<p class="mb-4 text-2xl text-neutral-700 dark:text-neutral-300">
|
||||
Here's your tweet:
|
||||
</p>
|
||||
<a
|
||||
href={data.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xl text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{data.url}
|
||||
</a>
|
||||
</div>
|
||||
{:else if data.type === 'reddit'}
|
||||
<div class="p-12 text-center">
|
||||
<p class="mb-4 text-2xl text-neutral-700 dark:text-neutral-300">
|
||||
Here's your Reddit post:
|
||||
</p>
|
||||
<a
|
||||
href={data.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xl text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{data.url}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-12 text-center">
|
||||
<p class="mb-4 text-2xl text-neutral-700 dark:text-neutral-300">
|
||||
Here's what you tried to link:
|
||||
</p>
|
||||
<a
|
||||
href={data.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xl text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{data.url}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer Message with Button -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 text-center text-neutral-600 dark:text-neutral-400">
|
||||
<p>
|
||||
You need to link a <strong class="text-neutral-900 dark:text-neutral-50">GitHub repository</strong>
|
||||
</p>
|
||||
<a
|
||||
href="/databases/new?name={encodeURIComponent(data.formData.name)}&branch={encodeURIComponent(data.formData.branch)}&sync_strategy={encodeURIComponent(data.formData.syncStrategy)}&auto_pull={encodeURIComponent(data.formData.autoPull)}"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
Try Again
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
335
src/routes/databases/components/InstanceForm.svelte
Normal file
335
src/routes/databases/components/InstanceForm.svelte
Normal file
@@ -0,0 +1,335 @@
|
||||
<script lang="ts">
|
||||
import { Save, Trash2, Loader2 } from 'lucide-svelte';
|
||||
import Modal from '$ui/modal/Modal.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import type { DatabaseInstance } from '$db/queries/databaseInstances.ts';
|
||||
|
||||
// Props
|
||||
export let mode: 'create' | 'edit';
|
||||
export let instance: DatabaseInstance | undefined = undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export let form: any = undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export let data: any = undefined;
|
||||
|
||||
// Loading state
|
||||
let isLoading = false;
|
||||
|
||||
// Form values
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let name = (form as any)?.values?.name ?? (mode === 'edit' ? instance?.name : data?.formData?.name) ?? '';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let repositoryUrl =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form as any)?.values?.repository_url ??
|
||||
(mode === 'edit' ? instance?.repository_url : '') ??
|
||||
'';
|
||||
let branch = (form as any)?.values?.branch ?? (mode === 'create' ? data?.formData?.branch : '') ?? '';
|
||||
let personalAccessToken = (form as any)?.values?.personal_access_token ?? (mode === 'edit' ? instance?.personal_access_token : data?.formData?.personalAccessToken) ?? '';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let syncStrategy =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form as any)?.values?.sync_strategy ??
|
||||
(mode === 'edit' ? instance?.sync_strategy : (data?.formData?.syncStrategy ? parseInt(data.formData.syncStrategy) : 60)) ??
|
||||
60;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let autoPull = (form as any)?.values?.auto_pull ?? (mode === 'edit' ? instance?.auto_pull : (data?.formData?.autoPull === '1' ? 1 : (data?.formData?.autoPull === '0' ? 0 : 1))) ?? 1;
|
||||
|
||||
// Delete modal state
|
||||
let showDeleteModal = false;
|
||||
let deleteFormElement: HTMLFormElement;
|
||||
|
||||
// Display text based on mode
|
||||
$: title = mode === 'create' ? 'Link Database' : 'Edit Database';
|
||||
$: description =
|
||||
mode === 'create'
|
||||
? 'Link a Profilarr Compliant Database from a Git repository'
|
||||
: `Update the configuration for ${instance?.name || 'this database'}`;
|
||||
$: submitButtonText = mode === 'create' ? 'Link Database' : 'Save Changes';
|
||||
$: successMessage =
|
||||
mode === 'create' ? 'Database linked successfully!' : 'Database updated successfully!';
|
||||
$: errorMessage = mode === 'create' ? 'Failed to link database' : 'Failed to update database';
|
||||
</script>
|
||||
|
||||
<div class="space-y-8 p-8">
|
||||
<div class="space-y-3">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">{title}</h1>
|
||||
<p class="text-lg text-neutral-600 dark:text-neutral-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
class="space-y-6"
|
||||
use:enhance={() => {
|
||||
isLoading = true;
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure' && result.data) {
|
||||
alertStore.add('error', (result.data as { error?: string }).error || errorMessage);
|
||||
} else if (result.type === 'redirect') {
|
||||
// Don't show success message if redirecting to bruh page
|
||||
if (result.location && !result.location.includes('/databases/bruh')) {
|
||||
alertStore.add('success', successMessage);
|
||||
}
|
||||
}
|
||||
await update();
|
||||
isLoading = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- Database Details -->
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
Database Details
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder="e.g., Main Database, 4K Profiles"
|
||||
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
A friendly name to identify this database
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Repository URL -->
|
||||
<div>
|
||||
<label
|
||||
for="repository_url"
|
||||
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Repository URL <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="repository_url"
|
||||
name="repository_url"
|
||||
bind:value={repositoryUrl}
|
||||
required
|
||||
disabled={mode === 'edit'}
|
||||
placeholder="https://github.com/username/database"
|
||||
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900"
|
||||
/>
|
||||
{#if mode === 'edit'}
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Repository URL cannot be changed after linking
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Git repository URL containing the PCD manifest
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Branch -->
|
||||
{#if mode === 'create'}
|
||||
<div>
|
||||
<label
|
||||
for="branch"
|
||||
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Branch
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="branch"
|
||||
name="branch"
|
||||
bind:value={branch}
|
||||
placeholder="main"
|
||||
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Branch to checkout on link. Leave empty to use the default branch. You can change this later.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Personal Access Token -->
|
||||
<div>
|
||||
<label
|
||||
for="personal_access_token"
|
||||
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Personal Access Token (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="personal_access_token"
|
||||
name="personal_access_token"
|
||||
bind:value={personalAccessToken}
|
||||
placeholder="ghp_..."
|
||||
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Required for private repositories to clone and for developers to push back to GitHub.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Settings -->
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
Sync Settings
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Sync Strategy -->
|
||||
<div>
|
||||
<label
|
||||
for="sync_strategy"
|
||||
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Sync Strategy
|
||||
</label>
|
||||
<select
|
||||
id="sync_strategy"
|
||||
name="sync_strategy"
|
||||
bind:value={syncStrategy}
|
||||
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
>
|
||||
<option value={0}>Manual (no auto-sync)</option>
|
||||
<option value={5}>Every 5 minutes</option>
|
||||
<option value={15}>Every 15 minutes</option>
|
||||
<option value={30}>Every 30 minutes</option>
|
||||
<option value={60}>Every hour</option>
|
||||
<option value={360}>Every 6 hours</option>
|
||||
<option value={720}>Every 12 hours</option>
|
||||
<option value={1440}>Every 24 hours</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
How often to check for updates from the remote repository
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto Pull -->
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="auto_pull"
|
||||
name="auto_pull"
|
||||
bind:checked={autoPull}
|
||||
value="1"
|
||||
class="mt-0.5 h-4 w-4 rounded border-neutral-300 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
for="auto_pull"
|
||||
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Automatically pull updates
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
If enabled, updates will be pulled automatically. If disabled, you'll only receive
|
||||
notifications when updates are available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||
{#if mode === 'edit'}
|
||||
<a
|
||||
href="/databases/{instance?.id}"
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
{#if isLoading}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{mode === 'create' ? 'Linking...' : 'Saving...'}
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
{submitButtonText}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Delete Section (Edit Mode Only) -->
|
||||
{#if mode === 'edit'}
|
||||
<div
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-800 dark:bg-red-950/40"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-red-700 dark:text-red-300">Danger Zone</h2>
|
||||
<p class="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
Once you unlink this database, there is no going back. All local data will be removed.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showDeleteModal = true)}
|
||||
class="mt-4 flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-700 dark:bg-neutral-900 dark:text-red-300 dark:hover:bg-red-900"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Unlink Database
|
||||
</button>
|
||||
|
||||
<form
|
||||
bind:this={deleteFormElement}
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
class="hidden"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure' && result.data) {
|
||||
alertStore.add(
|
||||
'error',
|
||||
(result.data as { error?: string }).error || 'Failed to unlink database'
|
||||
);
|
||||
} else if (result.type === 'redirect') {
|
||||
alertStore.add('success', 'Database unlinked successfully');
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- Empty form, just for submission -->
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if mode === 'edit'}
|
||||
<Modal
|
||||
open={showDeleteModal}
|
||||
header="Unlink Database"
|
||||
bodyMessage={`Are you sure you want to unlink "${instance?.name}"? This action cannot be undone and all local data will be permanently removed.`}
|
||||
confirmText="Unlink"
|
||||
cancelText="Cancel"
|
||||
confirmDanger={true}
|
||||
on:confirm={() => {
|
||||
showDeleteModal = false;
|
||||
deleteFormElement?.requestSubmit();
|
||||
}}
|
||||
on:cancel={() => (showDeleteModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
113
src/routes/databases/new/+page.server.ts
Normal file
113
src/routes/databases/new/+page.server.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, ServerLoad } from '@sveltejs/kit';
|
||||
import { pcdManager } from '$pcd/pcd.ts';
|
||||
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const load: ServerLoad = ({ url }) => {
|
||||
const name = url.searchParams.get('name') || '';
|
||||
const branch = url.searchParams.get('branch') || '';
|
||||
const syncStrategy = url.searchParams.get('sync_strategy') || '';
|
||||
const autoPull = url.searchParams.get('auto_pull') || '';
|
||||
|
||||
return {
|
||||
formData: {
|
||||
name,
|
||||
branch,
|
||||
syncStrategy,
|
||||
autoPull
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
|
||||
const name = formData.get('name')?.toString().trim();
|
||||
const repositoryUrl = formData.get('repository_url')?.toString().trim();
|
||||
const branch = formData.get('branch')?.toString().trim() || undefined;
|
||||
const syncStrategy = parseInt(formData.get('sync_strategy')?.toString() || '0', 10);
|
||||
const autoPull = formData.get('auto_pull') === '1';
|
||||
const personalAccessToken = formData.get('personal_access_token')?.toString().trim() || undefined;
|
||||
|
||||
// Validation
|
||||
if (!name || !repositoryUrl) {
|
||||
await logger.warn('Attempted to link database with missing required fields', {
|
||||
source: 'databases/new',
|
||||
meta: { name, repositoryUrl }
|
||||
});
|
||||
|
||||
return fail(400, {
|
||||
error: 'Name and repository URL are required',
|
||||
values: { name, repository_url: repositoryUrl }
|
||||
});
|
||||
}
|
||||
|
||||
// Check for common non-GitHub URLs and redirect to bruh page
|
||||
const bruhParams = new URLSearchParams({
|
||||
name: name,
|
||||
branch: branch || '',
|
||||
sync_strategy: syncStrategy.toString(),
|
||||
auto_pull: autoPull ? '1' : '0'
|
||||
});
|
||||
|
||||
if (repositoryUrl.includes('youtube.com') || repositoryUrl.includes('youtu.be')) {
|
||||
redirect(303, `/databases/bruh?url=${encodeURIComponent(repositoryUrl)}&type=youtube&${bruhParams.toString()}`);
|
||||
}
|
||||
if (repositoryUrl.includes('twitter.com') || repositoryUrl.includes('x.com')) {
|
||||
redirect(303, `/databases/bruh?url=${encodeURIComponent(repositoryUrl)}&type=twitter&${bruhParams.toString()}`);
|
||||
}
|
||||
if (repositoryUrl.includes('reddit.com')) {
|
||||
redirect(303, `/databases/bruh?url=${encodeURIComponent(repositoryUrl)}&type=reddit&${bruhParams.toString()}`);
|
||||
}
|
||||
|
||||
// Check if name already exists
|
||||
if (databaseInstancesQueries.nameExists(name)) {
|
||||
await logger.warn('Attempted to link database with duplicate name', {
|
||||
source: 'databases/new',
|
||||
meta: { name }
|
||||
});
|
||||
|
||||
return fail(400, {
|
||||
error: 'A database with this name already exists',
|
||||
values: { name, repository_url: repositoryUrl }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Link the database
|
||||
const instance = await pcdManager.link({
|
||||
name,
|
||||
repositoryUrl,
|
||||
branch,
|
||||
syncStrategy,
|
||||
autoPull,
|
||||
personalAccessToken
|
||||
});
|
||||
|
||||
await logger.info(`Linked new database: ${name}`, {
|
||||
source: 'databases/new',
|
||||
meta: { id: instance.id, name, repositoryUrl }
|
||||
});
|
||||
|
||||
// Redirect to databases list
|
||||
redirect(303, '/databases');
|
||||
} catch (error) {
|
||||
// Re-throw redirect errors (they're not actual errors)
|
||||
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await logger.error('Failed to link database', {
|
||||
source: 'databases/new',
|
||||
meta: { error: error instanceof Error ? error.message : String(error) }
|
||||
});
|
||||
|
||||
return fail(500, {
|
||||
error: error instanceof Error ? error.message : 'Failed to link database',
|
||||
values: { name, repository_url: repositoryUrl }
|
||||
});
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
9
src/routes/databases/new/+page.svelte
Normal file
9
src/routes/databases/new/+page.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import InstanceForm from '../components/InstanceForm.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
export let form: ActionData;
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<InstanceForm mode="create" {form} {data} />
|
||||
@@ -3,6 +3,7 @@
|
||||
import { alertStore } from '$alerts/store';
|
||||
import DiscordConfiguration from './DiscordConfiguration.svelte';
|
||||
import { siDiscord } from 'simple-icons';
|
||||
import { groupNotificationTypesByCategory } from '$shared/notificationTypes';
|
||||
|
||||
export let mode: 'create' | 'edit' = 'create';
|
||||
export let initialData: {
|
||||
@@ -15,31 +16,8 @@
|
||||
let selectedType: 'discord' | 'slack' | 'email' = (initialData.serviceType as 'discord') || 'discord';
|
||||
let serviceName = initialData.name || '';
|
||||
|
||||
// Available notification types
|
||||
const notificationTypes = [
|
||||
{ id: 'job.create_backup.success', label: 'Backup Created (Success)', category: 'Backups' },
|
||||
{ id: 'job.create_backup.failed', label: 'Backup Created (Failed)', category: 'Backups' },
|
||||
{
|
||||
id: 'job.cleanup_backups.success',
|
||||
label: 'Backup Cleanup (Success)',
|
||||
category: 'Backups'
|
||||
},
|
||||
{ id: 'job.cleanup_backups.failed', label: 'Backup Cleanup (Failed)', category: 'Backups' },
|
||||
{ id: 'job.cleanup_logs.success', label: 'Log Cleanup (Success)', category: 'Logs' },
|
||||
{ id: 'job.cleanup_logs.failed', label: 'Log Cleanup (Failed)', category: 'Logs' }
|
||||
];
|
||||
|
||||
// Group notification types by category
|
||||
const groupedTypes = notificationTypes.reduce(
|
||||
(acc, type) => {
|
||||
if (!acc[type.category]) {
|
||||
acc[type.category] = [];
|
||||
}
|
||||
acc[type.category].push(type);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof notificationTypes>
|
||||
);
|
||||
const groupedTypes = groupNotificationTypesByCategory();
|
||||
|
||||
// Check if a notification type should be checked by default
|
||||
function isTypeEnabled(typeId: string): boolean {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Actions, RequestEvent } from '@sveltejs/kit';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
import { notificationServicesQueries } from '$db/queries/notificationServices.ts';
|
||||
import { getAllNotificationTypeIds } from '$shared/notificationTypes.ts';
|
||||
|
||||
export const load = ({ params }: { params: { id: string } }) => {
|
||||
const { id } = params;
|
||||
@@ -84,22 +85,9 @@ export const actions: Actions = {
|
||||
enable_mentions: enableMentions
|
||||
};
|
||||
|
||||
// Get enabled notification types
|
||||
const jobBackupSuccess = formData.get('job.create_backup.success') === 'on';
|
||||
const jobBackupFailed = formData.get('job.create_backup.failed') === 'on';
|
||||
const jobCleanupBackupsSuccess = formData.get('job.cleanup_backups.success') === 'on';
|
||||
const jobCleanupBackupsFailed = formData.get('job.cleanup_backups.failed') === 'on';
|
||||
const jobCleanupLogsSuccess = formData.get('job.cleanup_logs.success') === 'on';
|
||||
const jobCleanupLogsFailed = formData.get('job.cleanup_logs.failed') === 'on';
|
||||
|
||||
enabledTypes = [
|
||||
...(jobBackupSuccess ? ['job.create_backup.success'] : []),
|
||||
...(jobBackupFailed ? ['job.create_backup.failed'] : []),
|
||||
...(jobCleanupBackupsSuccess ? ['job.cleanup_backups.success'] : []),
|
||||
...(jobCleanupBackupsFailed ? ['job.cleanup_backups.failed'] : []),
|
||||
...(jobCleanupLogsSuccess ? ['job.cleanup_logs.success'] : []),
|
||||
...(jobCleanupLogsFailed ? ['job.cleanup_logs.failed'] : [])
|
||||
];
|
||||
// Get enabled notification types dynamically from all available types
|
||||
const allTypeIds = getAllNotificationTypeIds();
|
||||
enabledTypes = allTypeIds.filter((typeId) => formData.get(typeId) === 'on');
|
||||
}
|
||||
|
||||
// Update the service
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Actions, RequestEvent } from '@sveltejs/kit';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
import { notificationServicesQueries } from '$db/queries/notificationServices.ts';
|
||||
import { getAllNotificationTypeIds } from '$shared/notificationTypes.ts';
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request }: RequestEvent) => {
|
||||
@@ -39,22 +40,9 @@ export const actions: Actions = {
|
||||
enable_mentions: enableMentions
|
||||
};
|
||||
|
||||
// Get enabled notification types
|
||||
const jobBackupSuccess = formData.get('job.create_backup.success') === 'on';
|
||||
const jobBackupFailed = formData.get('job.create_backup.failed') === 'on';
|
||||
const jobCleanupBackupsSuccess = formData.get('job.cleanup_backups.success') === 'on';
|
||||
const jobCleanupBackupsFailed = formData.get('job.cleanup_backups.failed') === 'on';
|
||||
const jobCleanupLogsSuccess = formData.get('job.cleanup_logs.success') === 'on';
|
||||
const jobCleanupLogsFailed = formData.get('job.cleanup_logs.failed') === 'on';
|
||||
|
||||
enabledTypes = [
|
||||
...(jobBackupSuccess ? ['job.create_backup.success'] : []),
|
||||
...(jobBackupFailed ? ['job.create_backup.failed'] : []),
|
||||
...(jobCleanupBackupsSuccess ? ['job.cleanup_backups.success'] : []),
|
||||
...(jobCleanupBackupsFailed ? ['job.cleanup_backups.failed'] : []),
|
||||
...(jobCleanupLogsSuccess ? ['job.cleanup_logs.success'] : []),
|
||||
...(jobCleanupLogsFailed ? ['job.cleanup_logs.failed'] : [])
|
||||
];
|
||||
// Get enabled notification types dynamically from all available types
|
||||
const allTypeIds = getAllNotificationTypeIds();
|
||||
enabledTypes = allTypeIds.filter((typeId) => formData.get(typeId) === 'on');
|
||||
}
|
||||
|
||||
// Generate UUID for the service
|
||||
|
||||
@@ -22,6 +22,7 @@ const config = {
|
||||
$server: './src/server',
|
||||
$db: './src/lib/server/db',
|
||||
$jobs: './src/lib/server/jobs',
|
||||
$pcd: './src/lib/server/pcd',
|
||||
$arr: './src/lib/server/utils/arr',
|
||||
$http: './src/lib/server/utils/http',
|
||||
$utils: './src/lib/server/utils',
|
||||
|
||||
Reference in New Issue
Block a user