Compare commits

...

25 Commits

Author SHA1 Message Date
KodeStar 5247349d33 Fix CI: pin Node 24 and install frontend deps with npm ci
CI ran `yarn && yarn dev` with no committed yarn.lock, so every run resolved
the latest matching versions and dependency drift broke the pipeline in two
independent ways:

- select2 4.1.0 added engines.node ">=24" but the runner used node 22, so
  `yarn install` failed outright on every pull request.
- webpack 5.108 removed lib/SizeFormatHelpers, which laravel-mix 6 still
  requires, so `yarn dev` would have failed the build regardless of node.

Switch the workflow to `npm ci`, which installs the exact, known-good
versions already pinned in package-lock.json (select2 4.0.13, webpack
5.100.1) and is verified to build and lint cleanly. Pin the runner to node
24 via actions/setup-node so the toolchain is explicit rather than tracking
the runner default. Also pin select2 to ~4.0.13 in package.json so a future
`npm install` cannot pull the incompatible 4.1.0 back in.
2026-07-08 16:06:35 +01:00
KodeStar 5907a1f231 Merge pull request #1559 from JoshSalway/queue-safety-2026-04-22
[2.x] Bound retry and unique-lock lifetimes on UpdateApps and ProcessApps
2026-07-08 09:35:47 +01:00
KodeStar df0eba046b Merge pull request #1563 from Nyuwb/patch-1
fix: proxy options in ItemController
2026-07-08 09:34:39 +01:00
Fabien Ehrlich 6a776e30f8 fix: proxy options in ItemController
The correct context is http->proxy :

https://www.php.net/manual/en/context.http.php
2026-05-13 11:35:13 +02:00
Josh Salway cbf099be2c Remove QueueFailedHandlerTest from the shipped suite
Reproduction-style behavior test, not a regression test, so it does
not belong in the main suite. Full test remains in this branch's
commit history at 56c53ab9 for reviewers:

    git checkout 56c53ab9 -- tests/Feature/QueueFailedHandlerTest.php
    vendor/bin/phpunit --filter QueueFailedHandlerTest

Keeps the PR aligned with Heimdall's existing test conventions
(HTTP-feature tests, no facade-mocking unit tests).
2026-04-22 20:16:11 +10:00
Josh Salway 56c53ab9c5 Prove failed() log shape with behavior test
Tests that UpdateApps::failed() and ProcessApps::failed():
 - call Log::error with the 'permanently failed' message
 - include exception_class, exception_message, and file context keys
 - for UpdateApps, still call Cache::lock('updateApps')->forceRelease()

Uses Log::spy() and Mockery to capture facade calls. 2 tests, 4
assertions, green on PHP 8.4.20. See follow-up commit for why this
lands in history but not in the shipped suite.
2026-04-22 20:15:54 +10:00
Josh Salway 98b6d96cd1 Enrich failed() log context with exception class and file:line
Previously logged only the exception message. Adds:
 - exception_class: distinguishes ClientException vs ConnectException
   vs other Guzzle/PHP failure types at a glance
 - file: file path and line where the exception was raised, useful
   for distinguishing 'failed inside Guzzle' from 'failed inside our
   code path'

The exception message itself often contains the GitHub API URL,
which encodes the app identifier. Capturing the specific appid at
the moment of failure would require touching handle() to track the
current iteration; left as a follow-up.
2026-04-22 17:01:18 +10:00
Josh Salway 5be7a65677 Tighten retry shape: $tries=1, $uniqueFor=600, drop $timeout and $backoff
Most failures for these jobs are GitHub API rate-limit responses;
retries inside the same window do not help, so one attempt is enough
and $backoff has nothing to pace.

$timeout would clip the intentionally throttled handle() loop
(sleep(1) per app) below realistic workloads. Heavy users with 60+
apps would lose updates mid-cycle. The original code left $timeout
unset, and `UpdateApps` is dispatched via `dispatchAfterResponse()`
which doesn't go through `queue:work` at all (the queue $timeout
is irrelevant in that path). Letting the operator's worker config
govern is more honest.

$uniqueFor reduced to 600 (10 min) since with $tries=1 +
worker-governed timeout there is no long retry chain to outlive.
Lock self-heals 10 minutes after a crashed worker.
2026-04-22 16:57:49 +10:00
Josh Salway 44be3cb319 Drop tests/Feature/QueueSafetyTest.php
The test only asserted property values and method_exists. The diff
itself shows the property values; the test added zero confidence
beyond reading the diff. Heimdall is an app, not a package; its
existing tests are HTTP feature tests against user behavior, not
class-property assertions. Removing this file keeps the PR aligned
with the existing test conventions.
2026-04-22 16:56:21 +10:00
Josh Salway 243ad00810 Bound retry and unique-lock lifetimes on UpdateApps and ProcessApps
Both jobs implement ShouldBeUnique without a $uniqueFor value, which on
Redis and database drivers produces a lock that never expires. If the
worker is killed mid-fire (OOM, SIGKILL, server crash), the lock
persists and blocks all future dispatches of UpdateApps or ProcessApps
until the cache entry is manually cleared.

Neither job sets $tries, $backoff, or $timeout, so they inherit the
worker command's defaults (1 for queue:work, 0 for vapor:work), which
varies by platform and is brittle.

This change adds:
 - $uniqueFor = 3600   lock expires after 1 hour
 - $tries = 3          hard cap across worker restarts
 - $backoff = [30, 60, 120]  pace retries to reduce GitHub API load
 - $timeout = 60       bound per-attempt wall-clock time
 - failed(Throwable)    log permanent failures (and preserve the
                        existing Cache::lock('updateApps')->forceRelease
                        on UpdateApps)

A new test (tests/Feature/QueueSafetyTest.php) asserts the retry
properties are present.

All existing tests still pass.
2026-04-22 14:55:01 +10:00
KodeStar 7861ae1512 Merge pull request #1524 from KodeStar/remove_dropdown_on_single_provider
Mark stale issues and pull requests / stale (push) Has been cancelled
Remove search provider dropdown when there's only a single provider
2025-11-11 12:05:43 +00:00
Chris Hunt 66dfe95c9f Fix lint issue 2025-11-11 12:02:53 +00:00
Chris Hunt 130661bd34 Remove search provider dropdown when there's only a single provider
Resolves #1509
2025-11-11 12:00:41 +00:00
KodeStar 900fc83e79 Merge pull request #1523 from KodeStar/add_autocomplete_suggestions
Add autocomplete suggestions support
2025-11-11 11:43:55 +00:00
Chris Hunt 4f30332854 Fix lint issues 2025-11-11 11:42:38 +00:00
Chris Hunt 852c231724 Add autocomplete suggestions support and added to bing, duckduckgo, and google 2025-11-11 11:39:06 +00:00
KodeStar 045bdf0deb Merge pull request #1507 from KodeStar/taglist
Mark stale issues and pull requests / stale (push) Has been cancelled
Fix tag list url when tags are treated as tags
2025-09-16 09:53:06 +01:00
Chris Hunt 755c3e59e1 Fix tag list url when tags are treated as tags 2025-09-16 09:50:03 +01:00
KodeStar 32bf1d034f Add password field to fix #1498 2025-09-15 16:49:42 +01:00
Chris Hunt 6d12c547e7 Add password field 2025-09-15 16:42:53 +01:00
KodeStar ae4ce92dab Merge pull request #1503 from KodeStar/background_max_file_size
Add current background maxsize #1501
2025-09-15 16:28:52 +01:00
KodeStar 966279b252 Merge pull request #1499 from webmogul1/patch-1
Update app.php
2025-09-15 16:28:28 +01:00
KodeStar 54cf2b88ca Update application version to 2.7.6 2025-09-15 16:28:00 +01:00
Chris Hunt 31f1ba8192 Add current background maxsize #1501 2025-09-15 16:26:10 +01:00
webmogul1 eadd9d1dd8 Update app.php
Change version to 2.7.5
2025-09-11 14:46:50 -04:00
22 changed files with 7174 additions and 28 deletions
+7 -7
View File
@@ -31,17 +31,17 @@ jobs:
cp .env.example .env
php artisan key:generate
- name: Cache yarn dependencies
uses: actions/cache@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
path: node_modules
key: yarn-${{ hashFiles('yarn.lock') }}
node-version: '24'
cache: 'npm'
- name: Run yarn
run: yarn && yarn dev
- name: Install node modules and build assets
run: npm ci && npm run dev
- name: Run ESLint
run: yarn lint
run: npm run lint
- name: Run tests
run: php artisan test
+11
View File
@@ -27,6 +27,17 @@ function format_bytes($bytes, bool $is_drive_size = true, string $beforeunit = '
}
}
function parse_size($size) {
$unit = strtolower(substr($size, -1));
$bytes = (int)$size;
switch($unit) {
case 'g': $bytes *= 1024 * 1024 * 1024; break;
case 'm': $bytes *= 1024 * 1024; break;
case 'k': $bytes *= 1024; break;
}
return $bytes;
}
/**
* @param $title
* @param string $separator
+1 -1
View File
@@ -271,7 +271,7 @@ class ItemController extends Controller
$httpsProxy = getenv('HTTPS_PROXY');
$httpsProxyLower = getenv('https_proxy');
if ($httpsProxy !== false || $httpsProxyLower !== false) {
$options['proxy']['http'] = $httpsProxy ?: $httpsProxyLower;
$options['http']['proxy'] = $httpsProxy ?: $httpsProxyLower;
}
$file = $request->input('icon');
+95
View File
@@ -4,9 +4,11 @@ namespace App\Http\Controllers;
use App\Search;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Http;
class SearchController extends Controller
{
@@ -41,4 +43,97 @@ class SearchController extends Controller
abort(404, 'Provider type not supported');
}
/**
* Get autocomplete suggestions for a search query
*
* @return JsonResponse
*/
public function autocomplete(Request $request)
{
$requestprovider = $request->input('provider');
$query = $request->input('q');
if (!$query || trim($query) === '') {
return response()->json([]);
}
$provider = Search::providerDetails($requestprovider);
if (!$provider || !isset($provider->autocomplete)) {
return response()->json([]);
}
// Replace {query} placeholder with actual query
$autocompleteUrl = str_replace('{query}', urlencode($query), $provider->autocomplete);
try {
$response = Http::timeout(5)->get($autocompleteUrl);
if ($response->successful()) {
$data = $response->body();
// Parse the response based on provider
$suggestions = $this->parseAutocompleteResponse($data, $provider->id);
return response()->json($suggestions);
}
} catch (\Exception $e) {
// Return empty array on error
return response()->json([]);
}
return response()->json([]);
}
/**
* Parse autocomplete response based on provider format
*
* @param string $data
* @param string $providerId
* @return array
*/
private function parseAutocompleteResponse($data, $providerId)
{
$suggestions = [];
switch ($providerId) {
case 'google':
// Google returns XML format
if (strpos($data, '<?xml') === 0) {
$xml = simplexml_load_string($data);
if ($xml && isset($xml->CompleteSuggestion)) {
foreach ($xml->CompleteSuggestion as $suggestion) {
if (isset($suggestion->suggestion['data'])) {
$suggestions[] = (string) $suggestion->suggestion['data'];
}
}
}
}
break;
case 'bing':
case 'ddg':
// Bing and DuckDuckGo return JSON array format
$json = json_decode($data, true);
if (is_array($json) && isset($json[1]) && is_array($json[1])) {
$suggestions = $json[1];
}
break;
default:
// Try to parse as JSON array
$json = json_decode($data, true);
if (is_array($json)) {
if (isset($json[1]) && is_array($json[1])) {
$suggestions = $json[1];
} else {
$suggestions = $json;
}
}
break;
}
return $suggestions;
}
}
+2
View File
@@ -101,6 +101,8 @@ class TagController extends Controller
$data['tag'] = $item->id;
$data['all_apps'] = $item->children;
$data['taglist'] = Item::ofType('tag')->where('id', '>', 0)->orderBy('title', 'asc')->get();
return view('welcome', $data);
}
+22
View File
@@ -14,11 +14,24 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
class ProcessApps implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Most failures here are GitHub rate-limit responses; retries inside the
* same window do not help, so a single attempt is enough.
*/
public int $tries = 1;
/**
* Expire the ShouldBeUnique lock after 10 minutes so a crashed worker
* does not permanently block future ProcessApps dispatches.
*/
public int $uniqueFor = 600;
/**
* Create a new job instance.
*
@@ -57,4 +70,13 @@ class ProcessApps implements ShouldQueue, ShouldBeUnique
}
}
}
public function failed(Throwable $exception): void
{
Log::error(static::class . ' permanently failed', [
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
'file' => $exception->getFile() . ':' . $exception->getLine(),
]);
}
}
+22 -1
View File
@@ -12,11 +12,26 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;
class UpdateApps implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Most failures here are GitHub rate-limit responses; retries inside the
* same window do not help, so a single attempt is enough. The throttle
* loop in handle() means the job is intentionally long-running, so we
* leave $timeout unset and let the operator's worker config govern.
*/
public int $tries = 1;
/**
* Expire the ShouldBeUnique lock after 10 minutes so a crashed worker
* does not permanently block future UpdateApps dispatches.
*/
public int $uniqueFor = 600;
/**
* Create a new job instance.
*
@@ -49,8 +64,14 @@ class UpdateApps implements ShouldQueue, ShouldBeUnique
Cache::lock('updateApps')->forceRelease();
}
public function failed($exception): void
public function failed(Throwable $exception): void
{
Cache::lock('updateApps')->forceRelease();
Log::error(static::class . ' permanently failed', [
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
'file' => $exception->getFile() . ':' . $exception->getLine(),
]);
}
}
+20 -5
View File
@@ -111,16 +111,31 @@ abstract class Search
if ((bool) $user_search_provider) {
$name = 'app.options.'.$user_search_provider;
$provider = self::providerDetails($user_search_provider);
$providers = self::providers();
$providerCount = count($providers);
// If there's only one provider, use its key instead of the user's setting
if ($providerCount === 1) {
$user_search_provider = $providers->keys()->first();
}
$output .= '<div class="searchform">';
$output .= '<form action="'.url('search').'"'.getLinkTargetAttribute().' method="get">';
$output .= '<div id="search-container" class="input-container">';
$output .= '<select name="provider">';
foreach (self::providers() as $key => $searchprovider) {
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
// Only show dropdown if there's more than one provider
if ($providerCount > 1) {
$output .= '<select name="provider">';
foreach ($providers as $key => $searchprovider) {
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
}
$output .= '</select>';
} else {
// Hidden input for single provider
$output .= '<input type="hidden" name="provider" value="'.$user_search_provider.'" />';
}
$output .= '</select>';
$output .= '<input type="text" name="q" value="'.e(Input::get('q') ?? '').'" class="homesearch" autofocus placeholder="'.__('app.settings.search').'..." />';
$output .= '<button type="submit">'.ucwords(__('app.settings.search')).'</button>';
$output .= '</div>';
+7
View File
@@ -21,6 +21,13 @@ class CustomFormBuilder
);
}
public function password($name, $options = [])
{
return new HtmlString(
$this->html->input('password', $name)->attributes($options)
);
}
public function hidden($name, $value = null, $options = [])
{
return new HtmlString(
+1 -1
View File
@@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Facade;
return [
'version' => '2.7.4',
'version' => '2.7.7',
'appsource' => env('APP_SOURCE', 'https://appslist.heimdall.site/'),
+1 -1
View File
@@ -5,7 +5,7 @@
"packages": {
"": {
"dependencies": {
"select2": "^4.0.13",
"select2": "~4.0.13",
"sortablejs": "^1.15.0"
},
"devDependencies": {
+1 -1
View File
@@ -26,7 +26,7 @@
"webpack-cli": "^6.0.1"
},
"dependencies": {
"select2": "^4.0.13",
"select2": "~4.0.13",
"sortablejs": "^1.15.0"
}
}
+2183 -2
View File
File diff suppressed because one or more lines are too long
+4636 -1
View File
File diff suppressed because one or more lines are too long
-1
View File
File diff suppressed because one or more lines are too long
+2 -3
View File
@@ -1,5 +1,4 @@
{
"/js/dummy.js": "/js/dummy.js?id=daec5f3b283a510837bec36ca3868a54",
"/css/app.css": "/css/app.css?id=8e5c9ae35dd160a37c9d33d663f996b9",
"/js/app.js": "/js/app.js?id=19052619246fec368cad13937c62d850"
"/css/app.css": "/css/app.css?id=271cb5f5a1f91d0a6dfbc65e374ffc14",
"/js/app.js": "/js/app.js?id=2ebeb753597d1cbbf88d8bc652e4af5b"
}
+101 -1
View File
@@ -108,11 +108,90 @@ $.when($.ready).then(() => {
}
});
// Autocomplete functionality
let autocompleteTimeout = null;
let currentAutocompleteRequest = null;
function hideAutocomplete() {
$("#search-autocomplete").remove();
}
function showAutocomplete(suggestions, inputElement) {
hideAutocomplete();
if (!suggestions || suggestions.length === 0) {
return;
}
const $input = $(inputElement);
const position = $input.position();
const width = $input.outerWidth();
const $autocomplete = $('<div id="search-autocomplete"></div>');
suggestions.forEach((suggestion) => {
const $item = $('<div class="autocomplete-item"></div>')
.text(suggestion)
.on("click", () => {
$input.val(suggestion);
hideAutocomplete();
$input.closest("form").submit();
});
$autocomplete.append($item);
});
$autocomplete.css({
position: "absolute",
top: `${position.top + $input.outerHeight()}px`,
left: `${position.left}px`,
width: `${width}px`,
});
$input.closest("#search-container").append($autocomplete);
}
function fetchAutocomplete(query, provider) {
// Cancel previous request if any
if (currentAutocompleteRequest) {
currentAutocompleteRequest.abort();
}
if (!query || query.trim().length < 2) {
hideAutocomplete();
return;
}
currentAutocompleteRequest = $.ajax({
url: `${base}search/autocomplete`,
method: "GET",
data: {
q: query,
provider,
},
success(data) {
const inputElement = $("#search-container input[name=q]")[0];
showAutocomplete(data, inputElement);
},
error() {
hideAutocomplete();
},
complete() {
currentAutocompleteRequest = null;
},
});
}
$("#search-container")
.on("input", "input[name=q]", function () {
const search = this.value;
const items = $("#sortable").find(".item-container");
if ($("#search-container select[name=provider]").val() === "tiles") {
// Get provider from either select or hidden input
const provider =
$("#search-container select[name=provider]").val() ||
$("#search-container input[name=provider]").val();
if (provider === "tiles") {
hideAutocomplete();
if (search.length > 0) {
items.hide();
items
@@ -126,6 +205,12 @@ $.when($.ready).then(() => {
}
} else {
items.show();
// Debounce autocomplete requests
clearTimeout(autocompleteTimeout);
autocompleteTimeout = setTimeout(() => {
fetchAutocomplete(search, provider);
}, 300);
}
})
.on("change", "select[name=provider]", function () {
@@ -147,9 +232,24 @@ $.when($.ready).then(() => {
} else {
$("#search-container button").show();
items.show();
hideAutocomplete();
}
});
// Hide autocomplete when clicking outside
$(document).on("click", (e) => {
if (!$(e.target).closest("#search-container").length) {
hideAutocomplete();
}
});
// Hide autocomplete on Escape key
$(document).on("keydown", (e) => {
if (e.key === "Escape") {
hideAutocomplete();
}
});
$("#search-container select[name=provider]").trigger("change");
$("#app")
+46 -1
View File
@@ -926,6 +926,12 @@ div.create {
max-width: 620px;
position: relative;
z-index: 4;
// Reduce width when there's no select dropdown (only has hidden input)
&:has(input[name="provider"][type="hidden"]) {
max-width: 520px;
}
form {
width: 100%;
}
@@ -933,7 +939,6 @@ div.create {
background: white;
border-radius: 5px;
box-shadow: 0px 0px 5px 0 rgba(0,0,0,0.4);
overflow: hidden;
position: relative;
display: flex;
@@ -945,6 +950,11 @@ div.create {
width: 100%;
background: transparent;
}
// When there's no select dropdown, round the input's left corners
input[name="q"]:first-child {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
button {
position: absolute;
right: 0px;
@@ -965,7 +975,42 @@ div.create {
background: #f5f5f5;
border: none;
border-right: 1px solid #ddd;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
// When select exists, remove input's left border radius
select ~ input[name="q"] {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
#search-autocomplete {
position: absolute;
z-index: 1000;
background: white;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
box-shadow: 0px 4px 8px 0 rgba(0,0,0,0.2);
max-height: 300px;
overflow-y: auto;
.autocomplete-item {
padding: 12px 15px;
cursor: pointer;
font-size: 15px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #f5f5f5;
}
}
}
.ui-autocomplete {
+12 -1
View File
@@ -1,7 +1,18 @@
<section class="module-container">
@if($enable_auth_admin_controls)
<header>
<div class="section-title">{{ __($setting->label) }}</div>
<div class="section-title">
{{ __($setting->label) }}
@if($setting->type === 'image')
@php
$max_upload = ini_get('upload_max_filesize');
$max_upload_bytes = parse_size($max_upload);
@endphp
<a class="settinglink" target="_blank" rel="nofollow noreferer" href="https://github.com/linuxserver/Heimdall?tab=readme-ov-file#new-background-image-not-being-set">({{ format_bytes($max_upload_bytes, false) }})</a>
@endif
</div>
<div class="module-actions">
<button type="submit"class="button"><i class="fa fa-save"></i><span>{{ __('app.buttons.save') }}</span></button>
<a href="{{ route('settings.index', []) }}" class="button"><i class="fa fa-ban"></i><span>{{ __('app.buttons.cancel') }}</span></a>
+1
View File
@@ -75,6 +75,7 @@ Route::post('test_config', [ItemController::class,'testConfig'])->name('test_con
Route::get('get_stats/{id}', [ItemController::class,'getStats'])->name('get_stats');
Route::get('/search', [SearchController::class,'index'])->name('search');
Route::get('/search/autocomplete', [SearchController::class,'autocomplete'])->name('search.autocomplete');
Route::get('view/{name_view}', function ($name_view) {
return view('SupportedApps::'.$name_view)->render();
+3
View File
@@ -18,6 +18,7 @@ bing:
method: get
target: _blank
query: q
autocomplete: https://api.bing.com/osjson.aspx?query={query}
ddg:
id: ddg
@@ -26,6 +27,7 @@ ddg:
method: get
target: _blank
query: q
autocomplete: https://duckduckgo.com/ac/?q={query}&type=list
google:
id: google
@@ -34,6 +36,7 @@ google:
method: get
target: _blank
query: q
autocomplete: https://suggestqueries.google.com/complete/search?output=toolbar&hl=en&q={query}
startpage:
id: startpage
-1
View File
@@ -12,7 +12,6 @@ const mix = require("laravel-mix");
*/
mix
.js("resources/assets/js/app.js", "public/js/dummy.js")
.babel(
[
"node_modules/sortablejs/Sortable.min.js",