# Adding Jobs to the Cydekick Console

The Console plugin (`plugins/webkul/console`) shows a real-time terminal panel in the Filament header. Any queued job can appear in it. There are two ways to instrument a job.

---

## Jobs tab vs Activity tab — how the split actually works

Both tabs read the same table (`job_activity_logs`) — there is no `parent_id`, `batch_id`, or `type` column. The split is entirely one nullable column:

```php
'jobs'     => whereNull('message')     // JOBS tab
'activity' => whereNotNull('message')  // ACTIVITY tab
```

**Jobs = "did it run, and how did it finish."** One row per job/batch execution: `running` → `ok`/`error`, optionally with a progress bar. This is what auto-logging (Option A) and a bare `JobLogger::ok($summary, $logId)` (no third argument) both produce — the `message` column stays `NULL`, so it always lands here, however the row was created.

**Activity = "what is it actually doing, right now."** This is the flood of granular, per-item detail — `SKU {$sku} · updated 3 images`, `SKU {$sku} · price set £12.99` — one line per real thing that happened, so the user can watch it work and catch a mistake as it happens, not just see a spinner.

**The one thing that actually controls which tab an entry lands in is whether you pass `$details`:**

```php
JobLogger::ok("SKU · {$sku} · updated 3 images", null);              // message = NULL  → JOBS tab (not what you want here!)
JobLogger::ok("SKU · {$sku} · updated 3 images", null, 'ok');        // message = 'ok'   → ACTIVITY tab
```

Passing `null` as `$logId` only controls *whether a new row is created vs. an existing one updated* — it does **not** by itself route anything to Activity. That's a separate, orthogonal decision from whether `message` is set. **A per-item activity entry needs both**: `$logId = null` (so every item gets its own row instead of overwriting the parent job's row) **and** a non-empty `$details` string (so it actually shows up under Activity instead of silently counting as a second "Jobs" row). Forgetting the third argument is the single most common mistake when adding this — see the real, correct pattern in `PushImagesToShopifyJob`:

```php
JobLogger::ok("SKU · {$row->sku} · {$channel->name}", null, count($shopifyImages) . ' image(s) pushed');
```

The `$details` string doesn't need to be a full multi-line block (see "The detail block" below for when it should be) — even a short one-line string is enough to flip an entry into Activity. The important part is that it's non-empty.

---

## Option A — Auto-logging (zero code changes)

Jobs that are NOT in the `skipClasses` list in `ConsoleServiceProvider` are logged automatically via queue events (`JobProcessing`, `JobProcessed`, `JobFailed`). You get:

- A `running` entry when the job starts
- An `ok` or `error` entry when it finishes
- No progress bar, no per-item activity

This is fine for short jobs where you just want a record that something ran.

**You don't need to do anything.** If your job is not in `skipClasses`, it already appears.

---

## Option B — Explicit logging (recommended for long-running jobs)

Use `JobLogger` directly inside your job's `handle()` method. This gives you:

- A custom display name (e.g. channel name, row counts)
- A live progress bar in the console panel
- Clean cancellation / error messages

### Step 1 — Add the import

```php
use Webkul\Console\Services\JobLogger;
```

### Step 2 — Start a log entry

At the top of `handle()`, before the main work begins:

```php
$logId = JobLogger::running('My Job Name · starting…');
```

The string is shown in the status bar widget and the console panel. Keep it short.

### Step 3 — Report progress (optional but recommended)

If your job has a known total (e.g. iterating rows), call `progress()` periodically. Throttle it to avoid hammering the DB — every 25 rows is good for fast-paced jobs, every 100 for slower ones:

```php
$lastProgress = 0;

// Inside your loop / callback:
if (($done + $errors) - $lastProgress >= 25) {
    JobLogger::progress($logId, 'My Job Name', $done + $errors, $total);
    $lastProgress = $done + $errors;
}
```

`progress()` updates the display name to `My Job Name · 1,200 / 9,088 · 13%` and renders a small progress bar in the console panel.

If you don't know the total upfront (e.g. streaming from an API), use `updateName()` instead:

```php
JobLogger::updateName($logId, "My Job · {$count} processed");
```

### Step 4 — Mark complete or failed

```php
// Happy path
JobLogger::ok('My Job complete · ' . number_format($total) . ' rows', $logId);

// Error path (inside catch)
JobLogger::error('My Job failed · ' . $e->getMessage(), $logId);
```

Also add a `failed()` method for cases where the queue worker kills the job (timeout, uncaught exception):

```php
public function failed(\Throwable $e): void
{
    JobLogger::error('My Job failed · ' . $e->getMessage());
}
```

Note: `failed()` doesn't have the `$logId` in scope, so this creates a new `error` log entry rather than updating the existing `running` one. That's fine.

### Step 5 — Add to skipClasses

Open `plugins/webkul/console/src/ConsoleServiceProvider.php` and add your job class to `$skipClasses`:

```php
$skipClasses = [
    // ...existing entries...
    \Webkul\MyPlugin\Jobs\MyJob::class,
];
```

This prevents the auto-logger from also logging your job and creating a duplicate entry.

---

## Option C — Per-item activity entries (required for long-running batch jobs)

For any job that runs for more than a minute or processes more than ~20 items, the progress bar alone is not enough. The user needs to see **what is actually being processed** — a SKU reference, an action, a result — so they can:

1. Confirm the right products are being updated
2. Spot mistakes in real time (e.g. wrong price, wrong image)
3. Know the job is making progress, not hanging

### The pattern

Pass `null` as `$logId` to `JobLogger::ok()` **and** a non-empty `$details` string as the third argument. Both matter: `null` logId means every item gets its **own new row** instead of overwriting the parent job's row; the `$details` string is what actually flips that row into the **Activity** tab instead of Jobs (see the callout above — `message` is the only thing the tab query checks). The parent job's own row (tracked by its own `$logId`) keeps ticking its progress bar independently.

```php
// Inside the per-item loop — no throttle needed, every item gets an entry.
// The 3rd argument is REQUIRED for this to land in Activity, not Jobs —
// even a short one-line string is enough, it doesn't need to be a full block.
JobLogger::ok("SKU · {$sku} · {$channel->name} · updated 3 images", null, '3 images pushed');

// A real detail block works the same way — just more useful when expanded:
JobLogger::ok("SKU · {$sku} · price set £{$price}", null, "Old: £{$oldPrice}\nNew: £{$price}\neBay item: {$itemId}");
```

**Errors are the one exception** — `JobLogger::error(string $message, ?int $logId = null, ?string $jobClass = null)` has no `$details` parameter at all today. A per-item error call:

```php
JobLogger::error("SKU · {$sku} · FAILED · " . $e->getMessage(), null);
```

still creates its own standalone row (thanks to `null` logId), but since `error()` can never set `message`, that row's `message` stays `NULL` — it always lands in **Jobs**, never Activity, no matter how it's called. If you want failed items visible in the Activity flood too, that needs `JobLogger` extended with a `$details` param on `error()` first — worth doing if a job's errors need that visibility, but it's a real code change, not just a different call.

### Naming convention for per-item entries

Always lead with `SKU · {$sku}` so the user can cross-reference the console with eBay, Shopify, or the product page. Follow with the channel name (when relevant) and a brief past-tense action — summarise counts in the message itself rather than making the user expand the detail block to see how many things happened:

```
SKU · 217353 · Somerset4x4 eBay · price set £12.99
SKU · 217353 · Somerset4x4 Shopify · updated 3 images
SKU · LR-ABC123 · image overlaid · overlaid/5/a3f1b2c4/main.jpg
SKU · 217353 · FAILED · no eBay mapping found   (Jobs tab only — see error() note above)
```

Keep entries under ~80 characters before the optional detail block.

### When to throttle per-item entries

- **Jobs with < 500 items**: emit an entry for every item
- **Jobs with 500–5,000 items**: emit every item but consider errors-only for skipped/no-change items
- **Jobs with > 5,000 items** (e.g. bulk price recalc): emit only changed and errored items, skip unchanged

### The detail block (optional)

The second string in `JobLogger::ok($message, null, $detail)` renders as a monospaced block in the console when the entry is expanded. Use it for structured data the user might want to copy:

```php
$detail = implode("\n", [
    "eBay item:  {$itemId}",
    "Old price:  £{$oldPrice}",
    "New price:  £{$newPrice}",
    "Stock:      {$qty}",
]);
JobLogger::ok("SKU · {$sku} · {$channel->name} · price & qty updated", null, $detail);
```

### Full example: image push job with per-item activity

```php
public function handle(): void
{
    $channel = Channel::find($this->channelId);
    $logId   = JobLogger::running("Push Images · {$channel->name} · starting…");

    $rows  = /* ... query products ... */;
    $total = $rows->count();
    $done  = 0;
    $errors = 0;
    $lastProgress = 0;

    JobLogger::progress($logId, "Push Images · {$channel->name}", 0, $total);

    foreach ($rows as $row) {
        try {
            $pushedCount = /* ... push images, count how many ... */;

            // ✅ Per-item ACTIVITY entry — null logId = own new row, non-empty
            // 3rd arg = lands in Activity, not Jobs. Both are required.
            JobLogger::ok("SKU · {$row->sku} · {$channel->name} · updated {$pushedCount} images", null, "{$pushedCount} images pushed");
            $done++;
        } catch (\Throwable $e) {
            // ❌ Per-item error — still its own row (null logId), but error()
            // has no $details param, so this always lands in Jobs, not Activity.
            JobLogger::error("SKU · {$row->sku} · {$channel->name} · FAILED · " . $e->getMessage(), null);
            $errors++;
        }

        // Progress bar update — throttled to every 25 items
        if (($done + $errors) - $lastProgress >= 25) {
            JobLogger::progress($logId, "Push Images · {$channel->name}", $done + $errors, $total);
            $lastProgress = $done + $errors;
        }
    }

    $summary = "Push Images · {$channel->name} · {$done} updated";
    if ($errors > 0) {
        $summary .= ", {$errors} errors";
        JobLogger::error($summary, $logId);
    } else {
        JobLogger::ok($summary, $logId);
    }
}
```

---

## JobLogger API reference

| Method | Signature | Description |
|--------|-----------|-------------|
| `running` | `running(string $name, ?string $jobClass = null, ?string $dedupeKey = null): int` | Creates a `running` entry, returns the log ID. Pass `$dedupeKey` for frequently-recurring jobs (Option D) — reuses/updates the existing row for that key instead of creating a new one, and tags it `type = 'sync'` |
| `progress` | `progress(int $logId, string $label, int $done, int $total, ?string $phase = null): void` | Updates name + progress percentage; optional `$phase` sub-line |
| `updateName` | `updateName(int $logId, string $name): void` | Updates the display name only (no progress bar) |
| `ok` | `ok(string $name, ?int $logId = null, ?string $details = null): void` | Marks entry `ok`. `null` logId → standalone new row. Passing `$details` is what puts that row in **Activity**; omitting it leaves `message` NULL and the row shows in **Jobs** instead |
| `error` | `error(string $name, ?int $logId = null, ?string $jobClass = null): void` | Marks entry `error`. `null` logId → standalone new row. Has no `$details` param, so an error entry's `message` is always NULL — it always shows in **Jobs**, never Activity |

**Two separate decisions, easy to conflate:**
1. **`$logId`: null vs a real ID** — controls whether this call creates a brand-new row or updates an existing one. This is what makes per-item logging possible at all (every item gets its own row instead of all of them overwriting the same parent row).
2. **`$details`: set vs omitted** — controls which *tab* that row appears in (Jobs vs Activity), because the tab query is purely `whereNull('message')` vs `whereNotNull('message')`. Only `ok()` (and `info()`) can set it; `error()` currently cannot, so per-item errors always land in Jobs regardless of `$logId`.

Both together — `null` logId **and** a non-empty `$details` — is what actually floods the Activity tab with live item-by-item feedback.

---

## Option D — Deduplicated logging for frequently-recurring jobs

Some jobs run on a tight schedule forever — `SyncAmazonOrders` and `SyncEbayOrders` every 5 minutes, for example. Logged the normal way (Option B), every single run creates its own `running` → `ok` row, so the Jobs tab fills up with "Amazon Order Sync · 0 imported" a dozen times an hour, burying anything that actually matters.

For this specific shape of job — same logical thing, run repeatedly, where only the *latest* result matters — give it a **dedupe key** instead. `JobLogger::running()` takes an optional `$dedupeKey`: pass one and the call finds the existing row for that key and updates it in place (new display name, status reset to `running`, timestamps refreshed) instead of inserting a new row. First call ever for a key creates the row; every call after that reuses the same row and its `id` forever. The row is also auto-tagged `type = 'sync'` so it's identifiable in the DB even without knowing the key.

```php
$consoleId = JobLogger::running(
    "Amazon Order Sync · {$channel->name} · fetching…",
    dedupeKey: "amazon-order-sync:{$channel->id}",
);
```

Everything after that — `progress()`, `updateName()`, `ok()`, `error()` — is called exactly as in Option B, using the returned `$consoleId`. No other code changes; those methods already update-in-place when given a real log ID.

**Choosing the key**: it must be stable across runs of the *same* logical thing, but unique per distinct thing so separate instances don't collide into one row. Key by whatever identifies the specific target — usually a channel/connection ID, not just the job class name (a bare job-class key would merge e.g. two separate Amazon channels' sync status into one row, hiding one of them):

```php
"amazon-order-sync:{$channel->id}"     // one row per Amazon channel
"ebay-order-sync:{$channel->id}"       // one row per eBay channel
"psp-scheduled-scrape"                 // fine as a bare string if there's only ever one of these
```

**Why `created_at` gets refreshed too**: the console panel only shows rows from the last 24–48 hours (`JobActivityLog::scopeRecent()`), filtered on `created_at`. A dedupe row's `created_at` would otherwise be stamped once, at the very first run, and never touched again — so without special handling the row would silently vanish from the console after 2 days even while it kept updating happily every 5 minutes. `JobLogger::running()` refreshes `created_at` on every reuse specifically to keep the row inside that window for as long as the job keeps running on schedule.

**When NOT to use this**: only for jobs where losing the history of past runs is fine because the *current* status is all that matters — a live sync heartbeat, not an audit trail. If you need to see what happened on run #47 specifically, use Option B/C instead (or check the job's own audit table if it has one, e.g. `channels_amazon_order_syncs` — dedup logging is about the *console display*, it doesn't touch or replace whatever audit trail the job already writes to its own table).

---

## Jobs already instrumented

| Job | Progress bar | Per-item activity |
|-----|-------------|-------------------|
| `ScrapeAllmakesPsp` | ✅ every 100 rows | ❌ |
| `FetchShopifyProductsJob` | ✅ updateName | ❌ |
| `ImportShopifyProductsJob` | ✅ every 10 rows | ❌ |
| `ProcessProductExportJob` | ✅ every 100 rows | ❌ |
| `ProcessProductImageExportJob` | ✅ every 100 rows | ❌ |
| `EbayBatchSyncJob` | ✅ multiple phases | ✅ "Synced · {sku} · {channel}" per product |
| `PushImagesToShopifyJob` | ✅ every 25 rows | ✅ "SKU · {sku} · {channel} · N image(s) pushed" per product |
| `SyncAmazonOrders` | ✅ updateName | Dedupe key `amazon-order-sync:{channelId}` (Option D) |
| `SyncEbayOrders` | ✅ updateName | Dedupe key `ebay-order-sync:{channelId}` (Option D) |

Jobs marked ❌ in the per-item column are candidates for upgrading to Option C. Any job that runs for more than a minute should emit per-item entries. Jobs that run every few minutes forever are candidates for Option D instead, regardless of per-item status.

---

## How the console panel polls

Both the status bar widget and the console panel use `wire:poll.3000ms` — they re-render every 3 seconds. Progress updates and new per-item entries written to `job_activity_logs` appear within 3 seconds. There is no WebSocket dependency.
