Runware/ComfyUI fixes, dashboard links, import action, Leonardo plugin, widget, added status field and test connection button
This commit is contained in:
@@ -11,10 +11,12 @@ use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
@@ -38,12 +40,23 @@ class AiModelResource extends Resource
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Hinweise zu Model-Parametern')
|
||||
->description('Parameter werden je nach Plugin unterschiedlich verwendet: Runware/ComfyUI als JSON für Workflow/Model-Settings, Leonardo v2 akzeptiert z. B. width/height/style_ids/prompt_enhance. Prüfe die Plugin-Doku; Felder, die nicht zum Plugin passen, werden ignoriert.')
|
||||
->columns(1)
|
||||
->collapsible()
|
||||
->schema([]),
|
||||
TextInput::make('name')
|
||||
->required(),
|
||||
TextInput::make('model_id')
|
||||
->required(),
|
||||
TextInput::make('model_type')
|
||||
->nullable(),
|
||||
Select::make('api_provider_id')
|
||||
->label('API Provider')
|
||||
->relationship('primaryApiProvider', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
Toggle::make('enabled')
|
||||
->default(true),
|
||||
Textarea::make('parameters')
|
||||
|
||||
@@ -9,6 +9,7 @@ use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
@@ -38,6 +39,12 @@ class ApiProviderResource extends Resource
|
||||
|
||||
return $schema
|
||||
->components([
|
||||
Placeholder::make('provider_dashboard')
|
||||
->label('Provider Dashboard')
|
||||
->content(fn (?ApiProvider $record) => $record?->getDashboardUrl() ? '<a href="'.$record->getDashboardUrl().'" target="_blank" class="text-primary-600 underline">'.$record->getDashboardUrl().'</a>' : '—')
|
||||
->disableLabel(false)
|
||||
->columnSpanFull()
|
||||
->visible(fn (?ApiProvider $record) => (bool) $record?->getDashboardUrl()),
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Filament\Resources\ApiProviders\Pages;
|
||||
|
||||
use App\Filament\Resources\ApiProviders\ApiProviderResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Livewire\Attributes\On;
|
||||
|
||||
@@ -19,8 +18,14 @@ class CreateApiProvider extends CreateRecord
|
||||
$this->testResultState = $result;
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No record yet, so no dashboard link; keep header clean.
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
namespace App\Filament\Resources\ApiProviders\Pages;
|
||||
|
||||
use App\Api\Plugins\PluginLoader;
|
||||
use App\Filament\Resources\ApiProviders\ApiProviderResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Livewire\Attributes\On;
|
||||
|
||||
@@ -22,6 +25,34 @@ class EditApiProvider extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('test_connection')
|
||||
->label('Verbindung testen')
|
||||
->icon('heroicon-o-bolt')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
try {
|
||||
$provider = $this->record;
|
||||
$plugin = PluginLoader::getPlugin($provider->plugin, $provider);
|
||||
$plugin->testConnection($provider->toArray());
|
||||
|
||||
Notification::make()
|
||||
->title('Verbindung erfolgreich')
|
||||
->success()
|
||||
->send();
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title('Verbindung fehlgeschlagen')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
Action::make('dashboard')
|
||||
->label('Provider Dashboard')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->visible(fn () => $this->record?->getDashboardUrl())
|
||||
->url(fn () => $this->record?->getDashboardUrl())
|
||||
->openUrlInNewTab(),
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
@@ -30,4 +61,4 @@ class EditApiProvider extends EditRecord
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Filament\Resources\Styles;
|
||||
use App\Models\Style;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\Action\Step;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
@@ -25,6 +26,7 @@ use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Spatie\SimpleExcel\SimpleExcelReader;
|
||||
|
||||
class StyleResource extends Resource
|
||||
{
|
||||
@@ -107,6 +109,79 @@ class StyleResource extends Resource
|
||||
->relationship('aiModel', 'name'),
|
||||
])
|
||||
->deferFilters(false)
|
||||
->headerActions([
|
||||
Action::make('import_styles')
|
||||
->label('Import Styles (CSV)')
|
||||
->icon('heroicon-o-arrow-up-on-square-stack')
|
||||
->steps([
|
||||
Step::make('Datei')
|
||||
->schema([
|
||||
FileUpload::make('import_file')
|
||||
->label('CSV-Datei (Titel,Prompt,[Beschreibung])')
|
||||
->acceptedFileTypes([
|
||||
'text/csv',
|
||||
'text/plain',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
])
|
||||
->directory('imports/styles')
|
||||
->visibility('private')
|
||||
->required(),
|
||||
]),
|
||||
Step::make('Zuordnung')
|
||||
->schema([
|
||||
Select::make('ai_model_id')
|
||||
->label('AI Modell')
|
||||
->relationship('aiModel', 'name')
|
||||
->required(),
|
||||
]),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$path = storage_path('app/'.$data['import_file']);
|
||||
|
||||
if (! file_exists($path)) {
|
||||
throw new \RuntimeException('Import-Datei nicht gefunden.');
|
||||
}
|
||||
|
||||
$rows = self::parseImportFile($path);
|
||||
|
||||
if (empty($rows)) {
|
||||
throw new \RuntimeException('Keine gültigen Zeilen gefunden.');
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
foreach ($rows as $row) {
|
||||
$title = $row['title'] ?? null;
|
||||
$prompt = $row['prompt'] ?? null;
|
||||
$description = $row['description'] ?? null;
|
||||
|
||||
if (! $title || ! $prompt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Style::create([
|
||||
'title' => $title,
|
||||
'prompt' => $prompt,
|
||||
'description' => $description ?? '',
|
||||
'preview_image' => '', // user must set later
|
||||
'ai_model_id' => $data['ai_model_id'],
|
||||
'enabled' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
$created++;
|
||||
}
|
||||
|
||||
if ($created === 0) {
|
||||
throw new \RuntimeException('Es wurden keine Styles importiert.');
|
||||
}
|
||||
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title('Import abgeschlossen')
|
||||
->body($created.' Styles importiert. Bitte Vorschau-Bilder ergänzen.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make(),
|
||||
Action::make('duplicate')
|
||||
@@ -149,6 +224,24 @@ class StyleResource extends Resource
|
||||
];
|
||||
}
|
||||
|
||||
protected static function parseImportFile(string $path): array
|
||||
{
|
||||
return SimpleExcelReader::create($path)->getRows()->map(function (array $row) {
|
||||
$normalized = [];
|
||||
foreach ($row as $key => $value) {
|
||||
$normalized[strtolower(trim($key))] = is_string($value) ? trim($value) : $value;
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $normalized['title'] ?? $normalized['titel'] ?? null,
|
||||
'prompt' => $normalized['prompt'] ?? null,
|
||||
'description' => $normalized['description'] ?? $normalized['beschreibung'] ?? null,
|
||||
];
|
||||
})->filter(function (array $row) {
|
||||
return ! empty($row['title']) && ! empty($row['prompt']);
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
275
app/Filament/Widgets/ApiProviderHealth.php
Normal file
275
app/Filament/Widgets/ApiProviderHealth.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Api\Plugins\PluginLoader;
|
||||
use App\Filament\Resources\ApiProviders\ApiProviderResource;
|
||||
use App\Models\ApiProvider;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ApiProviderHealth extends TableWidget
|
||||
{
|
||||
protected static ?string $heading = 'API Provider Health';
|
||||
|
||||
protected static ?string $description = 'Status, Tests & Quick enable';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected static ?int $sort = -5;
|
||||
|
||||
protected function getTableQuery(): Builder
|
||||
{
|
||||
return ApiProvider::query()
|
||||
->orderByRaw('enabled = 0 desc')
|
||||
->orderByRaw("case when coalesce(last_status, '') = 'online' then 1 else 0 end")
|
||||
->orderByDesc('updated_at');
|
||||
}
|
||||
|
||||
protected function getTableColumns(): array
|
||||
{
|
||||
return [
|
||||
TextColumn::make('name')
|
||||
->label('Provider')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->wrap()
|
||||
->url(fn (ApiProvider $record): string => ApiProviderResource::getUrl('edit', ['record' => $record]))
|
||||
->openUrlInNewTab(),
|
||||
TextColumn::make('plugin')
|
||||
->label('Plugin')
|
||||
->badge()
|
||||
->color('gray'),
|
||||
TextColumn::make('status_label')
|
||||
->label('Status')
|
||||
->badge()
|
||||
->state(fn (ApiProvider $record): string => $this->statusLabel($record))
|
||||
->color(fn (ApiProvider $record): string => $this->statusColor($record)),
|
||||
TextColumn::make('last_response_time_ms')
|
||||
->label('Latency')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? $state.' ms' : '—')
|
||||
->color('gray'),
|
||||
TextColumn::make('last_checked_at')
|
||||
->label('Geprüft')
|
||||
->formatStateUsing(fn ($state): string => $state ? $state->diffForHumans() : 'Noch nie')
|
||||
->sortable(),
|
||||
TextColumn::make('last_error')
|
||||
->label('Letzter Fehler')
|
||||
->limit(40)
|
||||
->tooltip(fn (ApiProvider $record): ?string => $record->last_error),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('test')
|
||||
->label('Testen')
|
||||
->icon('heroicon-o-bolt')
|
||||
->color('warning')
|
||||
->action(function (ApiProvider $record): void {
|
||||
$this->testProvider($record);
|
||||
}),
|
||||
Action::make('toggle')
|
||||
->label(fn (ApiProvider $record): string => $record->enabled ? 'Deaktivieren' : 'Aktivieren')
|
||||
->icon('heroicon-o-power')
|
||||
->color(fn (ApiProvider $record): string => $record->enabled ? 'gray' : 'success')
|
||||
->requiresConfirmation(fn (ApiProvider $record): bool => $record->enabled)
|
||||
->action(function (ApiProvider $record): void {
|
||||
if ($record->enabled) {
|
||||
$this->disableProvider($record);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->enableProvider($record, true);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('refresh')
|
||||
->label('Alle testen')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->testAllProviders();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableBulkActions(): array
|
||||
{
|
||||
return [
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('bulkEnable')
|
||||
->label('Aktivieren')
|
||||
->icon('heroicon-o-power')
|
||||
->color('success')
|
||||
->action(function (Collection $records): void {
|
||||
$records->each(function (ApiProvider $record): void {
|
||||
$this->enableProvider($record, false);
|
||||
});
|
||||
}),
|
||||
BulkAction::make('bulkDisable')
|
||||
->label('Deaktivieren')
|
||||
->icon('heroicon-o-stop')
|
||||
->color('gray')
|
||||
->action(function (Collection $records): void {
|
||||
$records->each(function (ApiProvider $record): void {
|
||||
$this->disableProvider($record);
|
||||
});
|
||||
}),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
private function testProvider(ApiProvider $provider): void
|
||||
{
|
||||
$result = $this->probeProvider($provider);
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($result['ok'] ? 'Verbindung erfolgreich' : 'Verbindung fehlgeschlagen')
|
||||
->body($result['error'] ?? 'Provider antwortet.');
|
||||
|
||||
$result['ok']
|
||||
? $notification->success()
|
||||
: $notification->danger();
|
||||
|
||||
$notification->send();
|
||||
}
|
||||
|
||||
private function testAllProviders(): void
|
||||
{
|
||||
ApiProvider::query()->get()->each(fn (ApiProvider $provider) => $this->probeProvider($provider));
|
||||
|
||||
Notification::make()
|
||||
->title('Tests gestartet')
|
||||
->body('Alle Provider wurden geprüft.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
private function enableProvider(ApiProvider $provider, bool $withTest = true): void
|
||||
{
|
||||
$ok = true;
|
||||
$result = null;
|
||||
|
||||
if ($withTest) {
|
||||
$result = $this->probeProvider($provider);
|
||||
$ok = $result['ok'];
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
$provider->forceFill(['enabled' => true])->save();
|
||||
|
||||
Notification::make()
|
||||
->title('Provider aktiviert')
|
||||
->body($provider->name.' ist aktiviert'.($result ? ' (Test ok).' : '.'))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Aktivierung abgebrochen')
|
||||
->body($result['error'] ?? 'Verbindung fehlgeschlagen.')
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
|
||||
private function disableProvider(ApiProvider $provider): void
|
||||
{
|
||||
$provider->disableWithDependencies();
|
||||
|
||||
Notification::make()
|
||||
->title('Provider deaktiviert')
|
||||
->body($provider->name.' wurde deaktiviert.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
private function probeProvider(ApiProvider $provider): array
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$status = $provider->enabled ? 'offline' : 'disabled';
|
||||
$error = null;
|
||||
$duration = null;
|
||||
$ok = false;
|
||||
|
||||
try {
|
||||
if (! $provider->plugin) {
|
||||
$status = 'plugin_missing';
|
||||
throw new RuntimeException('Kein Plugin definiert.');
|
||||
}
|
||||
|
||||
$plugin = PluginLoader::getPlugin($provider->plugin, $provider);
|
||||
$success = $plugin->testConnection($provider->toArray());
|
||||
$status = $success ? 'online' : 'offline';
|
||||
$ok = $success;
|
||||
} catch (Throwable $exception) {
|
||||
$status = match (true) {
|
||||
$status === 'disabled' => 'disabled',
|
||||
$status === 'plugin_missing' => 'plugin_missing',
|
||||
default => 'error',
|
||||
};
|
||||
$error = $exception->getMessage();
|
||||
} finally {
|
||||
$duration = (int) round((microtime(true) - $startedAt) * 1000);
|
||||
}
|
||||
|
||||
$provider->forceFill([
|
||||
'last_checked_at' => now(),
|
||||
'last_status' => $status,
|
||||
'last_response_time_ms' => $duration,
|
||||
'last_error' => $error,
|
||||
])->save();
|
||||
|
||||
return [
|
||||
'ok' => $ok,
|
||||
'error' => $error,
|
||||
'duration' => $duration,
|
||||
];
|
||||
}
|
||||
|
||||
private function statusLabel(ApiProvider $provider): string
|
||||
{
|
||||
if (! $provider->enabled) {
|
||||
return 'Disabled';
|
||||
}
|
||||
|
||||
return match ($provider->last_status) {
|
||||
'online' => 'Online',
|
||||
'offline' => 'Offline',
|
||||
'error' => 'Error',
|
||||
'plugin_missing' => 'Plugin fehlt',
|
||||
default => 'Unbekannt',
|
||||
};
|
||||
}
|
||||
|
||||
private function statusColor(ApiProvider $provider): string
|
||||
{
|
||||
if (! $provider->enabled) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
return match ($provider->last_status) {
|
||||
'online' => 'success',
|
||||
'offline' => 'danger',
|
||||
'error' => 'danger',
|
||||
'plugin_missing' => 'warning',
|
||||
default => 'warning',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user