first commit
This commit is contained in:
17
.editorconfig
Normal file
17
.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[{compose.yaml,compose.*.yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
33
.env
Normal file
33
.env
Normal file
@@ -0,0 +1,33 @@
|
||||
# In all environments, the following files are loaded if they exist,
|
||||
# the latter taking precedence over the former:
|
||||
#
|
||||
# * .env contains default values for the environment variables needed by the app
|
||||
# * .env.local uncommitted file with local overrides
|
||||
# * .env.$APP_ENV committed environment-specific defaults
|
||||
# * .env.$APP_ENV.local uncommitted environment-specific overrides
|
||||
#
|
||||
# Real environment variables win over .env files.
|
||||
#
|
||||
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
|
||||
# https://symfony.com/doc/current/configuration/secrets.html
|
||||
#
|
||||
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
|
||||
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
APP_ENV=dev
|
||||
APP_SECRET=
|
||||
APP_SHARE_DIR=var/share
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> symfony/routing ###
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
DEFAULT_URI=http://localhost
|
||||
###< symfony/routing ###
|
||||
|
||||
# DynamoDB / JormunDB connection
|
||||
DYNAMO_REGION=us-east-1
|
||||
DYNAMO_ENDPOINT=http://45.76.2.182:8002
|
||||
DYNAMO_KEY=AKIAIOSFODNN7EXAMPLE
|
||||
DYNAMO_SECRET=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
4
.env.dev
Normal file
4
.env.dev
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
APP_SECRET=b439afa29200b9f5e023a1d3e1adcd35
|
||||
###< symfony/framework-bundle ###
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
/.env.local
|
||||
/.env.local.php
|
||||
/.env.*.local
|
||||
/config/secrets/prod/prod.decrypt.private.php
|
||||
/public/bundles/
|
||||
/var/
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
1
README.md
Normal file
1
README.md
Normal file
@@ -0,0 +1 @@
|
||||
This is just a PoC right now. Bunch of LLM slop in here because I'm bad at front end.
|
||||
21
bin/console
Executable file
21
bin/console
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
|
||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||
}
|
||||
|
||||
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
|
||||
return new Application($kernel);
|
||||
};
|
||||
74
composer.json
Normal file
74
composer.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"aws/aws-sdk-php": "^3.372",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/dotenv": "7.4.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/form": "7.4.*",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/security-csrf": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/validator": "7.4.*",
|
||||
"symfony/yaml": "7.4.*",
|
||||
"twig/extra-bundle": "^3.23",
|
||||
"twig/twig": "^3.23"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": true,
|
||||
"symfony/flex": true,
|
||||
"symfony/runtime": true
|
||||
},
|
||||
"bump-after-update": true,
|
||||
"sort-packages": true
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"App\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"replace": {
|
||||
"symfony/polyfill-ctype": "*",
|
||||
"symfony/polyfill-iconv": "*",
|
||||
"symfony/polyfill-php72": "*",
|
||||
"symfony/polyfill-php73": "*",
|
||||
"symfony/polyfill-php74": "*",
|
||||
"symfony/polyfill-php80": "*",
|
||||
"symfony/polyfill-php81": "*",
|
||||
"symfony/polyfill-php82": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"auto-scripts": {
|
||||
"cache:clear": "symfony-cmd",
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||
},
|
||||
"post-install-cmd": [
|
||||
"@auto-scripts"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
]
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/symfony": "*"
|
||||
},
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "7.4.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
4673
composer.lock
generated
Normal file
4673
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
config/bundles.php
Normal file
7
config/bundles.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
|
||||
];
|
||||
19
config/packages/cache.yaml
Normal file
19
config/packages/cache.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
framework:
|
||||
cache:
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
#prefix_seed: your_vendor_name/app_name
|
||||
|
||||
# The "app" cache stores to the filesystem by default.
|
||||
# The data in this cache should persist between deploys.
|
||||
# Other options include:
|
||||
|
||||
# Redis
|
||||
#app: cache.adapter.redis
|
||||
#default_redis_provider: redis://localhost
|
||||
|
||||
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
|
||||
#app: cache.adapter.apcu
|
||||
|
||||
# Namespaced pools use the above "app" backend by default
|
||||
#pools:
|
||||
#my.dedicated.cache: null
|
||||
11
config/packages/csrf.yaml
Normal file
11
config/packages/csrf.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
# Enable stateless CSRF protection for forms and logins/logouts
|
||||
framework:
|
||||
form:
|
||||
csrf_protection:
|
||||
token_id: submit
|
||||
|
||||
csrf_protection:
|
||||
stateless_token_ids:
|
||||
- submit
|
||||
- authenticate
|
||||
- logout
|
||||
15
config/packages/framework.yaml
Normal file
15
config/packages/framework.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
|
||||
# Note that the session will be started ONLY if you read or write from it.
|
||||
session: true
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
3
config/packages/property_info.yaml
Normal file
3
config/packages/property_info.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
framework:
|
||||
property_info:
|
||||
with_constructor_extractor: true
|
||||
10
config/packages/routing.yaml
Normal file
10
config/packages/routing.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
framework:
|
||||
router:
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
default_uri: '%env(DEFAULT_URI)%'
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
router:
|
||||
strict_requirements: null
|
||||
6
config/packages/twig.yaml
Normal file
6
config/packages/twig.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
strict_variables: true
|
||||
11
config/packages/validator.yaml
Normal file
11
config/packages/validator.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
framework:
|
||||
validation:
|
||||
# Enables validator auto-mapping support.
|
||||
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
|
||||
#auto_mapping:
|
||||
# App\Entity\: []
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
validation:
|
||||
not_compromised_password: false
|
||||
5
config/preload.php
Normal file
5
config/preload.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||
}
|
||||
903
config/reference.php
Normal file
903
config/reference.php
Normal file
@@ -0,0 +1,903 @@
|
||||
<?php
|
||||
|
||||
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
|
||||
|
||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
|
||||
use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
|
||||
/**
|
||||
* This class provides array-shapes for configuring the services and bundles of an application.
|
||||
*
|
||||
* Services declared with the config() method below are autowired and autoconfigured by default.
|
||||
*
|
||||
* This is for apps only. Bundles SHOULD NOT use it.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```php
|
||||
* // config/services.php
|
||||
* namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
*
|
||||
* return App::config([
|
||||
* 'services' => [
|
||||
* 'App\\' => [
|
||||
* 'resource' => '../src/',
|
||||
* ],
|
||||
* ],
|
||||
* ]);
|
||||
* ```
|
||||
*
|
||||
* @psalm-type ImportsConfig = list<string|array{
|
||||
* resource: string,
|
||||
* type?: string|null,
|
||||
* ignore_errors?: bool,
|
||||
* }>
|
||||
* @psalm-type ParametersConfig = array<string, scalar|\UnitEnum|array<scalar|\UnitEnum|array<mixed>|Param|null>|Param|null>
|
||||
* @psalm-type ArgumentsType = list<mixed>|array<string, mixed>
|
||||
* @psalm-type CallType = array<string, ArgumentsType>|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool}
|
||||
* @psalm-type TagsType = list<string|array<string, array<string, mixed>>> // arrays inside the list must have only one element, with the tag name as the key
|
||||
* @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator
|
||||
* @psalm-type DeprecationType = array{package: string, version: string, message?: string}
|
||||
* @psalm-type DefaultsType = array{
|
||||
* public?: bool,
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* autowire?: bool,
|
||||
* autoconfigure?: bool,
|
||||
* bind?: array<string, mixed>,
|
||||
* }
|
||||
* @psalm-type InstanceofType = array{
|
||||
* shared?: bool,
|
||||
* lazy?: bool|string,
|
||||
* public?: bool,
|
||||
* properties?: array<string, mixed>,
|
||||
* configurator?: CallbackType,
|
||||
* calls?: list<CallType>,
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* autowire?: bool,
|
||||
* bind?: array<string, mixed>,
|
||||
* constructor?: string,
|
||||
* }
|
||||
* @psalm-type DefinitionType = array{
|
||||
* class?: string,
|
||||
* file?: string,
|
||||
* parent?: string,
|
||||
* shared?: bool,
|
||||
* synthetic?: bool,
|
||||
* lazy?: bool|string,
|
||||
* public?: bool,
|
||||
* abstract?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* factory?: CallbackType,
|
||||
* configurator?: CallbackType,
|
||||
* arguments?: ArgumentsType,
|
||||
* properties?: array<string, mixed>,
|
||||
* calls?: list<CallType>,
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* decorates?: string,
|
||||
* decoration_inner_name?: string,
|
||||
* decoration_priority?: int,
|
||||
* decoration_on_invalid?: 'exception'|'ignore'|null,
|
||||
* autowire?: bool,
|
||||
* autoconfigure?: bool,
|
||||
* bind?: array<string, mixed>,
|
||||
* constructor?: string,
|
||||
* from_callable?: CallbackType,
|
||||
* }
|
||||
* @psalm-type AliasType = string|array{
|
||||
* alias: string,
|
||||
* public?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* }
|
||||
* @psalm-type PrototypeType = array{
|
||||
* resource: string,
|
||||
* namespace?: string,
|
||||
* exclude?: string|list<string>,
|
||||
* parent?: string,
|
||||
* shared?: bool,
|
||||
* lazy?: bool|string,
|
||||
* public?: bool,
|
||||
* abstract?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* factory?: CallbackType,
|
||||
* arguments?: ArgumentsType,
|
||||
* properties?: array<string, mixed>,
|
||||
* configurator?: CallbackType,
|
||||
* calls?: list<CallType>,
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* autowire?: bool,
|
||||
* autoconfigure?: bool,
|
||||
* bind?: array<string, mixed>,
|
||||
* constructor?: string,
|
||||
* }
|
||||
* @psalm-type StackType = array{
|
||||
* stack: list<DefinitionType|AliasType|PrototypeType|array<class-string, ArgumentsType|null>>,
|
||||
* public?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* }
|
||||
* @psalm-type ServicesConfig = array{
|
||||
* _defaults?: DefaultsType,
|
||||
* _instanceof?: InstanceofType,
|
||||
* ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null>
|
||||
* }
|
||||
* @psalm-type ExtensionType = array<string, mixed>
|
||||
* @psalm-type FrameworkConfig = array{
|
||||
* secret?: scalar|Param|null,
|
||||
* http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false
|
||||
* allowed_http_method_override?: list<string|Param>|null,
|
||||
* trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%"
|
||||
* ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%"
|
||||
* test?: bool|Param,
|
||||
* default_locale?: scalar|Param|null, // Default: "en"
|
||||
* set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false
|
||||
* set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false
|
||||
* enabled_locales?: list<scalar|Param|null>,
|
||||
* trusted_hosts?: list<scalar|Param|null>,
|
||||
* trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"]
|
||||
* trusted_headers?: list<scalar|Param|null>,
|
||||
* error_controller?: scalar|Param|null, // Default: "error_controller"
|
||||
* handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true
|
||||
* csrf_protection?: bool|array{
|
||||
* enabled?: scalar|Param|null, // Default: null
|
||||
* stateless_token_ids?: list<scalar|Param|null>,
|
||||
* check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false
|
||||
* cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token"
|
||||
* },
|
||||
* form?: bool|array{ // Form configuration
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* csrf_protection?: bool|array{
|
||||
* enabled?: scalar|Param|null, // Default: null
|
||||
* token_id?: scalar|Param|null, // Default: null
|
||||
* field_name?: scalar|Param|null, // Default: "_token"
|
||||
* field_attr?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* },
|
||||
* http_cache?: bool|array{ // HTTP cache configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* debug?: bool|Param, // Default: "%kernel.debug%"
|
||||
* trace_level?: "none"|"short"|"full"|Param,
|
||||
* trace_header?: scalar|Param|null,
|
||||
* default_ttl?: int|Param,
|
||||
* private_headers?: list<scalar|Param|null>,
|
||||
* skip_response_headers?: list<scalar|Param|null>,
|
||||
* allow_reload?: bool|Param,
|
||||
* allow_revalidate?: bool|Param,
|
||||
* stale_while_revalidate?: int|Param,
|
||||
* stale_if_error?: int|Param,
|
||||
* terminate_on_cache_hit?: bool|Param,
|
||||
* },
|
||||
* esi?: bool|array{ // ESI configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* ssi?: bool|array{ // SSI configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* fragments?: bool|array{ // Fragments configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* hinclude_default_template?: scalar|Param|null, // Default: null
|
||||
* path?: scalar|Param|null, // Default: "/_fragment"
|
||||
* },
|
||||
* profiler?: bool|array{ // Profiler configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* collect?: bool|Param, // Default: true
|
||||
* collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null
|
||||
* only_exceptions?: bool|Param, // Default: false
|
||||
* only_main_requests?: bool|Param, // Default: false
|
||||
* dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler"
|
||||
* collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false
|
||||
* },
|
||||
* workflows?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* workflows?: array<string, array{ // Default: []
|
||||
* audit_trail?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* type?: "workflow"|"state_machine"|Param, // Default: "state_machine"
|
||||
* marking_store?: array{
|
||||
* type?: "method"|Param,
|
||||
* property?: scalar|Param|null,
|
||||
* service?: scalar|Param|null,
|
||||
* },
|
||||
* supports?: list<scalar|Param|null>,
|
||||
* definition_validators?: list<scalar|Param|null>,
|
||||
* support_strategy?: scalar|Param|null,
|
||||
* initial_marking?: list<scalar|Param|null>,
|
||||
* events_to_dispatch?: list<string|Param>|null,
|
||||
* places?: list<array{ // Default: []
|
||||
* name?: scalar|Param|null,
|
||||
* metadata?: array<string, mixed>,
|
||||
* }>,
|
||||
* transitions?: list<array{ // Default: []
|
||||
* name?: string|Param,
|
||||
* guard?: string|Param, // An expression to block the transition.
|
||||
* from?: list<array{ // Default: []
|
||||
* place?: string|Param,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* }>,
|
||||
* to?: list<array{ // Default: []
|
||||
* place?: string|Param,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* }>,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* metadata?: array<string, mixed>,
|
||||
* }>,
|
||||
* metadata?: array<string, mixed>,
|
||||
* }>,
|
||||
* },
|
||||
* router?: bool|array{ // Router configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* resource?: scalar|Param|null,
|
||||
* type?: scalar|Param|null,
|
||||
* cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%"
|
||||
* default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
|
||||
* http_port?: scalar|Param|null, // Default: 80
|
||||
* https_port?: scalar|Param|null, // Default: 443
|
||||
* strict_requirements?: scalar|Param|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true
|
||||
* utf8?: bool|Param, // Default: true
|
||||
* },
|
||||
* session?: bool|array{ // Session configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* storage_factory_id?: scalar|Param|null, // Default: "session.storage.factory.native"
|
||||
* handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null.
|
||||
* name?: scalar|Param|null,
|
||||
* cookie_lifetime?: scalar|Param|null,
|
||||
* cookie_path?: scalar|Param|null,
|
||||
* cookie_domain?: scalar|Param|null,
|
||||
* cookie_secure?: true|false|"auto"|Param, // Default: "auto"
|
||||
* cookie_httponly?: bool|Param, // Default: true
|
||||
* cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax"
|
||||
* use_cookies?: bool|Param,
|
||||
* gc_divisor?: scalar|Param|null,
|
||||
* gc_probability?: scalar|Param|null,
|
||||
* gc_maxlifetime?: scalar|Param|null,
|
||||
* save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null.
|
||||
* metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0
|
||||
* sid_length?: int|Param, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option.
|
||||
* sid_bits_per_character?: int|Param, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option.
|
||||
* },
|
||||
* request?: bool|array{ // Request configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* formats?: array<string, string|list<scalar|Param|null>>,
|
||||
* },
|
||||
* assets?: bool|array{ // Assets configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false
|
||||
* version_strategy?: scalar|Param|null, // Default: null
|
||||
* version?: scalar|Param|null, // Default: null
|
||||
* version_format?: scalar|Param|null, // Default: "%%s?%%s"
|
||||
* json_manifest_path?: scalar|Param|null, // Default: null
|
||||
* base_path?: scalar|Param|null, // Default: ""
|
||||
* base_urls?: list<scalar|Param|null>,
|
||||
* packages?: array<string, array{ // Default: []
|
||||
* strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false
|
||||
* version_strategy?: scalar|Param|null, // Default: null
|
||||
* version?: scalar|Param|null,
|
||||
* version_format?: scalar|Param|null, // Default: null
|
||||
* json_manifest_path?: scalar|Param|null, // Default: null
|
||||
* base_path?: scalar|Param|null, // Default: ""
|
||||
* base_urls?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* },
|
||||
* asset_mapper?: bool|array{ // Asset Mapper configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* paths?: array<string, scalar|Param|null>,
|
||||
* excluded_patterns?: list<scalar|Param|null>,
|
||||
* exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true
|
||||
* server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true
|
||||
* public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/"
|
||||
* missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn"
|
||||
* extensions?: array<string, scalar|Param|null>,
|
||||
* importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php"
|
||||
* importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims"
|
||||
* importmap_script_attributes?: array<string, scalar|Param|null>,
|
||||
* vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor"
|
||||
* precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip.
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* formats?: list<scalar|Param|null>,
|
||||
* extensions?: list<scalar|Param|null>,
|
||||
* },
|
||||
* },
|
||||
* translator?: bool|array{ // Translator configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* fallbacks?: list<scalar|Param|null>,
|
||||
* logging?: bool|Param, // Default: false
|
||||
* formatter?: scalar|Param|null, // Default: "translator.formatter.default"
|
||||
* cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations"
|
||||
* default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations"
|
||||
* paths?: list<scalar|Param|null>,
|
||||
* pseudo_localization?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* accents?: bool|Param, // Default: true
|
||||
* expansion_factor?: float|Param, // Default: 1.0
|
||||
* brackets?: bool|Param, // Default: true
|
||||
* parse_html?: bool|Param, // Default: false
|
||||
* localizable_html_attributes?: list<scalar|Param|null>,
|
||||
* },
|
||||
* providers?: array<string, array{ // Default: []
|
||||
* dsn?: scalar|Param|null,
|
||||
* domains?: list<scalar|Param|null>,
|
||||
* locales?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* globals?: array<string, string|array{ // Default: []
|
||||
* value?: mixed,
|
||||
* message?: string|Param,
|
||||
* parameters?: array<string, scalar|Param|null>,
|
||||
* domain?: string|Param,
|
||||
* }>,
|
||||
* },
|
||||
* validation?: bool|array{ // Validation configuration
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0.
|
||||
* enable_attributes?: bool|Param, // Default: true
|
||||
* static_method?: list<scalar|Param|null>,
|
||||
* translation_domain?: scalar|Param|null, // Default: "validators"
|
||||
* email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5"
|
||||
* mapping?: array{
|
||||
* paths?: list<scalar|Param|null>,
|
||||
* },
|
||||
* not_compromised_password?: bool|array{
|
||||
* enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true
|
||||
* endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null
|
||||
* },
|
||||
* disable_translation?: bool|Param, // Default: false
|
||||
* auto_mapping?: array<string, array{ // Default: []
|
||||
* services?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* },
|
||||
* annotations?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* serializer?: bool|array{ // Serializer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enable_attributes?: bool|Param, // Default: true
|
||||
* name_converter?: scalar|Param|null,
|
||||
* circular_reference_handler?: scalar|Param|null,
|
||||
* max_depth_handler?: scalar|Param|null,
|
||||
* mapping?: array{
|
||||
* paths?: list<scalar|Param|null>,
|
||||
* },
|
||||
* default_context?: array<string, mixed>,
|
||||
* named_serializers?: array<string, array{ // Default: []
|
||||
* name_converter?: scalar|Param|null,
|
||||
* default_context?: array<string, mixed>,
|
||||
* include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true
|
||||
* include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true
|
||||
* }>,
|
||||
* },
|
||||
* property_access?: bool|array{ // Property access configuration
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* magic_call?: bool|Param, // Default: false
|
||||
* magic_get?: bool|Param, // Default: true
|
||||
* magic_set?: bool|Param, // Default: true
|
||||
* throw_exception_on_invalid_index?: bool|Param, // Default: false
|
||||
* throw_exception_on_invalid_property_path?: bool|Param, // Default: true
|
||||
* },
|
||||
* type_info?: bool|array{ // Type info configuration
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* aliases?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* property_info?: bool|array{ // Property info configuration
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* with_constructor_extractor?: bool|Param, // Registers the constructor extractor.
|
||||
* },
|
||||
* cache?: array{ // Cache configuration
|
||||
* prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%"
|
||||
* app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem"
|
||||
* system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system"
|
||||
* directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app"
|
||||
* default_psr6_provider?: scalar|Param|null,
|
||||
* default_redis_provider?: scalar|Param|null, // Default: "redis://localhost"
|
||||
* default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost"
|
||||
* default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost"
|
||||
* default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection"
|
||||
* default_pdo_provider?: scalar|Param|null, // Default: null
|
||||
* pools?: array<string, array{ // Default: []
|
||||
* adapters?: list<scalar|Param|null>,
|
||||
* tags?: scalar|Param|null, // Default: null
|
||||
* public?: bool|Param, // Default: false
|
||||
* default_lifetime?: scalar|Param|null, // Default lifetime of the pool.
|
||||
* provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter.
|
||||
* early_expiration_message_bus?: scalar|Param|null,
|
||||
* clearer?: scalar|Param|null,
|
||||
* }>,
|
||||
* },
|
||||
* php_errors?: array{ // PHP errors handling configuration
|
||||
* log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true
|
||||
* throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true
|
||||
* },
|
||||
* exceptions?: array<string, array{ // Default: []
|
||||
* log_level?: scalar|Param|null, // The level of log message. Null to let Symfony decide. // Default: null
|
||||
* status_code?: scalar|Param|null, // The status code of the response. Null or 0 to let Symfony decide. // Default: null
|
||||
* log_channel?: scalar|Param|null, // The channel of log message. Null to let Symfony decide. // Default: null
|
||||
* }>,
|
||||
* web_link?: bool|array{ // Web links configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* lock?: bool|string|array{ // Lock configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* resources?: array<string, string|list<scalar|Param|null>>,
|
||||
* },
|
||||
* semaphore?: bool|string|array{ // Semaphore configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* resources?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* messenger?: bool|array{ // Messenger configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* routing?: array<string, string|array{ // Default: []
|
||||
* senders?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* serializer?: array{
|
||||
* default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer"
|
||||
* symfony_serializer?: array{
|
||||
* format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json"
|
||||
* context?: array<string, mixed>,
|
||||
* },
|
||||
* },
|
||||
* transports?: array<string, string|array{ // Default: []
|
||||
* dsn?: scalar|Param|null,
|
||||
* serializer?: scalar|Param|null, // Service id of a custom serializer to use. // Default: null
|
||||
* options?: array<string, mixed>,
|
||||
* failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
|
||||
* retry_strategy?: string|array{
|
||||
* service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null
|
||||
* max_retries?: int|Param, // Default: 3
|
||||
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
||||
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2
|
||||
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
||||
* jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1
|
||||
* },
|
||||
* rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null
|
||||
* }>,
|
||||
* failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
|
||||
* stop_worker_on_signals?: list<scalar|Param|null>,
|
||||
* default_bus?: scalar|Param|null, // Default: null
|
||||
* buses?: array<string, array{ // Default: {"messenger.bus.default":{"default_middleware":{"enabled":true,"allow_no_handlers":false,"allow_no_senders":true},"middleware":[]}}
|
||||
* default_middleware?: bool|string|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* allow_no_handlers?: bool|Param, // Default: false
|
||||
* allow_no_senders?: bool|Param, // Default: true
|
||||
* },
|
||||
* middleware?: list<string|array{ // Default: []
|
||||
* id?: scalar|Param|null,
|
||||
* arguments?: list<mixed>,
|
||||
* }>,
|
||||
* }>,
|
||||
* },
|
||||
* scheduler?: bool|array{ // Scheduler configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
|
||||
* http_client?: bool|array{ // HTTP Client configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
|
||||
* default_options?: array{
|
||||
* headers?: array<string, mixed>,
|
||||
* vars?: array<string, mixed>,
|
||||
* max_redirects?: int|Param, // The maximum number of redirects to follow.
|
||||
* http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.
|
||||
* resolve?: array<string, scalar|Param|null>,
|
||||
* proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection.
|
||||
* no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached.
|
||||
* timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter.
|
||||
* max_duration?: float|Param, // The maximum execution time for the request+response as a whole.
|
||||
* bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to.
|
||||
* verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context.
|
||||
* verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name.
|
||||
* cafile?: scalar|Param|null, // A certificate authority file.
|
||||
* capath?: scalar|Param|null, // A directory that contains multiple certificate authority files.
|
||||
* local_cert?: scalar|Param|null, // A PEM formatted certificate file.
|
||||
* local_pk?: scalar|Param|null, // A private key file.
|
||||
* passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file.
|
||||
* ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...)
|
||||
* peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es).
|
||||
* sha1?: mixed,
|
||||
* pin-sha256?: mixed,
|
||||
* md5?: mixed,
|
||||
* },
|
||||
* crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.
|
||||
* extra?: array<string, mixed>,
|
||||
* rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null
|
||||
* caching?: bool|array{ // Caching configuration.
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
|
||||
* shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true
|
||||
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
|
||||
* },
|
||||
* retry_failed?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null
|
||||
* http_codes?: array<string, array{ // Default: []
|
||||
* code?: int|Param,
|
||||
* methods?: list<string|Param>,
|
||||
* }>,
|
||||
* max_retries?: int|Param, // Default: 3
|
||||
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
||||
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2
|
||||
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
||||
* jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
|
||||
* },
|
||||
* },
|
||||
* mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable.
|
||||
* scoped_clients?: array<string, string|array{ // Default: []
|
||||
* scope?: scalar|Param|null, // The regular expression that the request URL must match before adding the other options. When none is provided, the base URI is used instead.
|
||||
* base_uri?: scalar|Param|null, // The URI to resolve relative URLs, following rules in RFC 3985, section 2.
|
||||
* auth_basic?: scalar|Param|null, // An HTTP Basic authentication "username:password".
|
||||
* auth_bearer?: scalar|Param|null, // A token enabling HTTP Bearer authorization.
|
||||
* auth_ntlm?: scalar|Param|null, // A "username:password" pair to use Microsoft NTLM authentication (requires the cURL extension).
|
||||
* query?: array<string, scalar|Param|null>,
|
||||
* headers?: array<string, mixed>,
|
||||
* max_redirects?: int|Param, // The maximum number of redirects to follow.
|
||||
* http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.
|
||||
* resolve?: array<string, scalar|Param|null>,
|
||||
* proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection.
|
||||
* no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached.
|
||||
* timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter.
|
||||
* max_duration?: float|Param, // The maximum execution time for the request+response as a whole.
|
||||
* bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to.
|
||||
* verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context.
|
||||
* verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name.
|
||||
* cafile?: scalar|Param|null, // A certificate authority file.
|
||||
* capath?: scalar|Param|null, // A directory that contains multiple certificate authority files.
|
||||
* local_cert?: scalar|Param|null, // A PEM formatted certificate file.
|
||||
* local_pk?: scalar|Param|null, // A private key file.
|
||||
* passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file.
|
||||
* ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...).
|
||||
* peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es).
|
||||
* sha1?: mixed,
|
||||
* pin-sha256?: mixed,
|
||||
* md5?: mixed,
|
||||
* },
|
||||
* crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.
|
||||
* extra?: array<string, mixed>,
|
||||
* rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null
|
||||
* caching?: bool|array{ // Caching configuration.
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
|
||||
* shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true
|
||||
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
|
||||
* },
|
||||
* retry_failed?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null
|
||||
* http_codes?: array<string, array{ // Default: []
|
||||
* code?: int|Param,
|
||||
* methods?: list<string|Param>,
|
||||
* }>,
|
||||
* max_retries?: int|Param, // Default: 3
|
||||
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
||||
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2
|
||||
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
||||
* jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
|
||||
* },
|
||||
* }>,
|
||||
* },
|
||||
* mailer?: bool|array{ // Mailer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null
|
||||
* dsn?: scalar|Param|null, // Default: null
|
||||
* transports?: array<string, scalar|Param|null>,
|
||||
* envelope?: array{ // Mailer Envelope configuration
|
||||
* sender?: scalar|Param|null,
|
||||
* recipients?: list<scalar|Param|null>,
|
||||
* allowed_recipients?: list<scalar|Param|null>,
|
||||
* },
|
||||
* headers?: array<string, string|array{ // Default: []
|
||||
* value?: mixed,
|
||||
* }>,
|
||||
* dkim_signer?: bool|array{ // DKIM signer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: ""
|
||||
* domain?: scalar|Param|null, // Default: ""
|
||||
* select?: scalar|Param|null, // Default: ""
|
||||
* passphrase?: scalar|Param|null, // The private key passphrase // Default: ""
|
||||
* options?: array<string, mixed>,
|
||||
* },
|
||||
* smime_signer?: bool|array{ // S/MIME signer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* key?: scalar|Param|null, // Path to key (in PEM format) // Default: ""
|
||||
* certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: ""
|
||||
* passphrase?: scalar|Param|null, // The private key passphrase // Default: null
|
||||
* extra_certificates?: scalar|Param|null, // Default: null
|
||||
* sign_options?: int|Param, // Default: null
|
||||
* },
|
||||
* smime_encrypter?: bool|array{ // S/MIME encrypter configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: ""
|
||||
* cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null
|
||||
* },
|
||||
* },
|
||||
* secrets?: bool|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%"
|
||||
* local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local"
|
||||
* decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET"
|
||||
* },
|
||||
* notifier?: bool|array{ // Notifier configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null
|
||||
* chatter_transports?: array<string, scalar|Param|null>,
|
||||
* texter_transports?: array<string, scalar|Param|null>,
|
||||
* notification_on_failed_messages?: bool|Param, // Default: false
|
||||
* channel_policy?: array<string, string|list<scalar|Param|null>>,
|
||||
* admin_recipients?: list<array{ // Default: []
|
||||
* email?: scalar|Param|null,
|
||||
* phone?: scalar|Param|null, // Default: ""
|
||||
* }>,
|
||||
* },
|
||||
* rate_limiter?: bool|array{ // Rate limiter configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* limiters?: array<string, array{ // Default: []
|
||||
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||
* storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
|
||||
* policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
||||
* limiters?: list<scalar|Param|null>,
|
||||
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
|
||||
* interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||
* rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket".
|
||||
* interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||
* amount?: int|Param, // Amount of tokens to add each interval. // Default: 1
|
||||
* },
|
||||
* }>,
|
||||
* },
|
||||
* uid?: bool|array{ // Uid configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* default_uuid_version?: 7|6|4|1|Param, // Default: 7
|
||||
* name_based_uuid_version?: 5|3|Param, // Default: 5
|
||||
* name_based_uuid_namespace?: scalar|Param|null,
|
||||
* time_based_uuid_version?: 7|6|1|Param, // Default: 7
|
||||
* time_based_uuid_node?: scalar|Param|null,
|
||||
* },
|
||||
* html_sanitizer?: bool|array{ // HtmlSanitizer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* sanitizers?: array<string, array{ // Default: []
|
||||
* allow_safe_elements?: bool|Param, // Allows "safe" elements and attributes. // Default: false
|
||||
* allow_static_elements?: bool|Param, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false
|
||||
* allow_elements?: array<string, mixed>,
|
||||
* block_elements?: list<string|Param>,
|
||||
* drop_elements?: list<string|Param>,
|
||||
* allow_attributes?: array<string, mixed>,
|
||||
* drop_attributes?: array<string, mixed>,
|
||||
* force_attributes?: array<string, array<string, string|Param>>,
|
||||
* force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false
|
||||
* allowed_link_schemes?: list<string|Param>,
|
||||
* allowed_link_hosts?: list<string|Param>|null,
|
||||
* allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false
|
||||
* allowed_media_schemes?: list<string|Param>,
|
||||
* allowed_media_hosts?: list<string|Param>|null,
|
||||
* allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false
|
||||
* with_attribute_sanitizers?: list<string|Param>,
|
||||
* without_attribute_sanitizers?: list<string|Param>,
|
||||
* max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0
|
||||
* }>,
|
||||
* },
|
||||
* webhook?: bool|array{ // Webhook configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
|
||||
* routing?: array<string, array{ // Default: []
|
||||
* service?: scalar|Param|null,
|
||||
* secret?: scalar|Param|null, // Default: ""
|
||||
* }>,
|
||||
* },
|
||||
* remote-event?: bool|array{ // RemoteEvent configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* json_streamer?: bool|array{ // JSON streamer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* }
|
||||
* @psalm-type TwigConfig = array{
|
||||
* form_themes?: list<scalar|Param|null>,
|
||||
* globals?: array<string, array{ // Default: []
|
||||
* id?: scalar|Param|null,
|
||||
* type?: scalar|Param|null,
|
||||
* value?: mixed,
|
||||
* }>,
|
||||
* autoescape_service?: scalar|Param|null, // Default: null
|
||||
* autoescape_service_method?: scalar|Param|null, // Default: null
|
||||
* base_template_class?: scalar|Param|null, // Deprecated: The child node "base_template_class" at path "twig.base_template_class" is deprecated.
|
||||
* cache?: scalar|Param|null, // Default: true
|
||||
* charset?: scalar|Param|null, // Default: "%kernel.charset%"
|
||||
* debug?: bool|Param, // Default: "%kernel.debug%"
|
||||
* strict_variables?: bool|Param, // Default: "%kernel.debug%"
|
||||
* auto_reload?: scalar|Param|null,
|
||||
* optimizations?: int|Param,
|
||||
* default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates"
|
||||
* file_name_pattern?: list<scalar|Param|null>,
|
||||
* paths?: array<string, mixed>,
|
||||
* date?: array{ // The default format options used by the date filter.
|
||||
* format?: scalar|Param|null, // Default: "F j, Y H:i"
|
||||
* interval_format?: scalar|Param|null, // Default: "%d days"
|
||||
* timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null
|
||||
* },
|
||||
* number_format?: array{ // The default format options for the number_format filter.
|
||||
* decimals?: int|Param, // Default: 0
|
||||
* decimal_point?: scalar|Param|null, // Default: "."
|
||||
* thousands_separator?: scalar|Param|null, // Default: ","
|
||||
* },
|
||||
* mailer?: array{
|
||||
* html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null
|
||||
* },
|
||||
* }
|
||||
* @psalm-type TwigExtraConfig = array{
|
||||
* cache?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* html?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* markdown?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* intl?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* cssinliner?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* inky?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* string?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* commonmark?: array{
|
||||
* renderer?: array{ // Array of options for rendering HTML.
|
||||
* block_separator?: scalar|Param|null,
|
||||
* inner_separator?: scalar|Param|null,
|
||||
* soft_break?: scalar|Param|null,
|
||||
* },
|
||||
* html_input?: "strip"|"allow"|"escape"|Param, // How to handle HTML input.
|
||||
* allow_unsafe_links?: bool|Param, // Remove risky link and image URLs by setting this to false. // Default: true
|
||||
* max_nesting_level?: int|Param, // The maximum nesting level for blocks. // Default: 9223372036854775807
|
||||
* max_delimiters_per_line?: int|Param, // The maximum number of strong/emphasis delimiters per line. // Default: 9223372036854775807
|
||||
* slug_normalizer?: array{ // Array of options for configuring how URL-safe slugs are created.
|
||||
* instance?: mixed,
|
||||
* max_length?: int|Param, // Default: 255
|
||||
* unique?: mixed,
|
||||
* },
|
||||
* commonmark?: array{ // Array of options for configuring the CommonMark core extension.
|
||||
* enable_em?: bool|Param, // Default: true
|
||||
* enable_strong?: bool|Param, // Default: true
|
||||
* use_asterisk?: bool|Param, // Default: true
|
||||
* use_underscore?: bool|Param, // Default: true
|
||||
* unordered_list_markers?: list<scalar|Param|null>,
|
||||
* },
|
||||
* ...<mixed>
|
||||
* },
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* twig_extra?: TwigExtraConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* twig_extra?: TwigExtraConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* twig_extra?: TwigExtraConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* twig_extra?: TwigExtraConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* ...<string, ExtensionType>,
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
final class App
|
||||
{
|
||||
/**
|
||||
* @param ConfigType $config
|
||||
*
|
||||
* @psalm-return ConfigType
|
||||
*/
|
||||
public static function config(array $config): array
|
||||
{
|
||||
/** @var ConfigType $config */
|
||||
$config = AppReference::config($config);
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
namespace Symfony\Component\Routing\Loader\Configurator;
|
||||
|
||||
/**
|
||||
* This class provides array-shapes for configuring the routes of an application.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```php
|
||||
* // config/routes.php
|
||||
* namespace Symfony\Component\Routing\Loader\Configurator;
|
||||
*
|
||||
* return Routes::config([
|
||||
* 'controllers' => [
|
||||
* 'resource' => 'routing.controllers',
|
||||
* ],
|
||||
* ]);
|
||||
* ```
|
||||
*
|
||||
* @psalm-type RouteConfig = array{
|
||||
* path: string|array<string,string>,
|
||||
* controller?: string,
|
||||
* methods?: string|list<string>,
|
||||
* requirements?: array<string,string>,
|
||||
* defaults?: array<string,mixed>,
|
||||
* options?: array<string,mixed>,
|
||||
* host?: string|array<string,string>,
|
||||
* schemes?: string|list<string>,
|
||||
* condition?: string,
|
||||
* locale?: string,
|
||||
* format?: string,
|
||||
* utf8?: bool,
|
||||
* stateless?: bool,
|
||||
* }
|
||||
* @psalm-type ImportConfig = array{
|
||||
* resource: string,
|
||||
* type?: string,
|
||||
* exclude?: string|list<string>,
|
||||
* prefix?: string|array<string,string>,
|
||||
* name_prefix?: string,
|
||||
* trailing_slash_on_root?: bool,
|
||||
* controller?: string,
|
||||
* methods?: string|list<string>,
|
||||
* requirements?: array<string,string>,
|
||||
* defaults?: array<string,mixed>,
|
||||
* options?: array<string,mixed>,
|
||||
* host?: string|array<string,string>,
|
||||
* schemes?: string|list<string>,
|
||||
* condition?: string,
|
||||
* locale?: string,
|
||||
* format?: string,
|
||||
* utf8?: bool,
|
||||
* stateless?: bool,
|
||||
* }
|
||||
* @psalm-type AliasConfig = array{
|
||||
* alias: string,
|
||||
* deprecated?: array{package:string, version:string, message?:string},
|
||||
* }
|
||||
* @psalm-type RoutesConfig = array{
|
||||
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
||||
* }
|
||||
*/
|
||||
final class Routes
|
||||
{
|
||||
/**
|
||||
* @param RoutesConfig $config
|
||||
*
|
||||
* @psalm-return RoutesConfig
|
||||
*/
|
||||
public static function config(array $config): array
|
||||
{
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
11
config/routes.yaml
Normal file
11
config/routes.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
|
||||
|
||||
# This file is the entry point to configure the routes of your app.
|
||||
# Methods with the #[Route] attribute are automatically imported.
|
||||
# See also https://symfony.com/doc/current/routing.html
|
||||
|
||||
# To list all registered routes, run the following command:
|
||||
# bin/console debug:router
|
||||
|
||||
controllers:
|
||||
resource: routing.controllers
|
||||
4
config/routes/framework.yaml
Normal file
4
config/routes/framework.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
when@dev:
|
||||
_errors:
|
||||
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||
prefix: /_error
|
||||
23
config/services.yaml
Normal file
23
config/services.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||
|
||||
# This file is the entry point to configure your own services.
|
||||
# Files in the packages/ subdirectory configure your dependencies.
|
||||
# See also https://symfony.com/doc/current/service_container/import.html
|
||||
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
_defaults:
|
||||
autowire: true # Automatically injects dependencies in your services.
|
||||
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
||||
|
||||
# makes classes in src/ available to be used as services
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
App\:
|
||||
resource: '../src/'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
9
public/index.php
Normal file
9
public/index.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
};
|
||||
0
src/Controller/.gitignore
vendored
Normal file
0
src/Controller/.gitignore
vendored
Normal file
51
src/Controller/AuthController.php
Normal file
51
src/Controller/AuthController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class AuthController extends AbstractController
|
||||
{
|
||||
#[Route('/login', name: 'login', methods: ['GET', 'POST'])]
|
||||
public function login(Request $request): Response
|
||||
{
|
||||
$session = $request->getSession();
|
||||
|
||||
// Already logged in
|
||||
if ($session->get('authenticated')) {
|
||||
return $this->redirectToRoute('dashboard');
|
||||
}
|
||||
|
||||
$error = null;
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$user = $request->request->get('username', '');
|
||||
$pass = $request->request->get('password', '');
|
||||
|
||||
$validUser = $_ENV['ADMIN_USER'] ?? 'root';
|
||||
$validPass = $_ENV['ADMIN_PASS'] ?? '1234';
|
||||
|
||||
if ($user === $validUser && $pass === $validPass) {
|
||||
$session->set('authenticated', true);
|
||||
$session->set('username', $user);
|
||||
return $this->redirectToRoute('dashboard');
|
||||
}
|
||||
|
||||
$error = 'Invalid credentials.';
|
||||
}
|
||||
|
||||
return $this->render('auth/login.html.twig', [
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/logout', name: 'logout')]
|
||||
public function logout(Request $request): Response
|
||||
{
|
||||
$request->getSession()->invalidate();
|
||||
return $this->redirectToRoute('login');
|
||||
}
|
||||
}
|
||||
50
src/Controller/DashboardController.php
Normal file
50
src/Controller/DashboardController.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\DynamoService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class DashboardController extends AbstractController
|
||||
{
|
||||
public function __construct(private DynamoService $dynamo) {}
|
||||
|
||||
#[Route('/', name: 'dashboard')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
if (!$request->getSession()->get('authenticated')) {
|
||||
return $this->redirectToRoute('login');
|
||||
}
|
||||
|
||||
$tables = [];
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
$tableNames = $this->dynamo->listTables();
|
||||
foreach ($tableNames as $name) {
|
||||
try {
|
||||
$desc = $this->dynamo->describeTable($name);
|
||||
$tables[] = [
|
||||
'name' => $name,
|
||||
'status' => $desc['TableStatus'] ?? 'UNKNOWN',
|
||||
'itemCount' => $desc['ItemCount'] ?? 0,
|
||||
'sizeBytes' => $desc['TableSizeBytes'] ?? 0,
|
||||
'keySchema' => $desc['KeySchema'] ?? [],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$tables[] = ['name' => $name, 'status' => 'ERROR', 'itemCount' => 0, 'sizeBytes' => 0, 'keySchema' => []];
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$error = 'Could not connect to JormunDB: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
return $this->render('dashboard/index.html.twig', [
|
||||
'tables' => $tables,
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
}
|
||||
313
src/Controller/TableController.php
Normal file
313
src/Controller/TableController.php
Normal file
@@ -0,0 +1,313 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\DynamoService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
#[Route('/table')]
|
||||
class TableController extends AbstractController
|
||||
{
|
||||
public function __construct(private DynamoService $dynamo) {}
|
||||
|
||||
private function requireAuth(Request $request): ?Response
|
||||
{
|
||||
if (!$request->getSession()->get('authenticated')) {
|
||||
return $this->redirectToRoute('login');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Browse ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/{name}', name: 'table_browse', methods: ['GET'])]
|
||||
public function browse(string $name, Request $request): Response
|
||||
{
|
||||
if ($r = $this->requireAuth($request)) return $r;
|
||||
|
||||
$tables = $this->dynamo->listTables();
|
||||
$lastKey = $request->query->get('lastKey') ? json_decode($request->query->get('lastKey'), true) : null;
|
||||
$limit = (int) $request->query->get('limit', 25);
|
||||
$toastMsg = $request->getSession()->get('toast');
|
||||
$request->getSession()->remove('toast');
|
||||
|
||||
try {
|
||||
$desc = $this->dynamo->describeTable($name);
|
||||
$result = $this->dynamo->scanTable($name, $lastKey, $limit);
|
||||
} catch (\Exception $e) {
|
||||
return $this->render('table/browse.html.twig', [
|
||||
'tableName' => $name,
|
||||
'tables' => $tables,
|
||||
'error' => $e->getMessage(),
|
||||
'items' => [],
|
||||
'columns' => [],
|
||||
'nextKey' => null,
|
||||
'prevKeys' => [],
|
||||
'keySchema' => [],
|
||||
'count' => 0,
|
||||
'toast' => $toastMsg,
|
||||
]);
|
||||
}
|
||||
|
||||
// Build columns from all items
|
||||
$columns = [];
|
||||
foreach ($result['items'] as $item) {
|
||||
foreach (array_keys($item) as $col) {
|
||||
$columns[$col] = true;
|
||||
}
|
||||
}
|
||||
$columns = array_keys($columns);
|
||||
|
||||
// Pagination history (stack of prev keys passed as JSON array in query)
|
||||
$prevKeys = $request->query->get('prevKeys') ? json_decode($request->query->get('prevKeys'), true) : [];
|
||||
$keySchema = $this->dynamo->getTableKeySchema($name);
|
||||
|
||||
return $this->render('table/browse.html.twig', [
|
||||
'tableName' => $name,
|
||||
'tables' => $tables,
|
||||
'items' => $result['items'],
|
||||
'columns' => $columns,
|
||||
'nextKey' => $result['nextKey'] ? json_encode($result['nextKey']) : null,
|
||||
'prevKeys' => $prevKeys,
|
||||
'currentKey' => $lastKey ? json_encode($lastKey) : null,
|
||||
'keySchema' => $keySchema,
|
||||
'count' => $result['count'],
|
||||
'scannedCount' => $result['scannedCount'],
|
||||
'limit' => $limit,
|
||||
'desc' => $desc,
|
||||
'error' => null,
|
||||
'toast' => $toastMsg,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Structure ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/{name}/structure', name: 'table_structure', methods: ['GET'])]
|
||||
public function structure(string $name, Request $request): Response
|
||||
{
|
||||
if ($r = $this->requireAuth($request)) return $r;
|
||||
|
||||
$tables = $this->dynamo->listTables();
|
||||
$error = null;
|
||||
$desc = null;
|
||||
|
||||
try {
|
||||
$desc = $this->dynamo->describeTable($name);
|
||||
} catch (\Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
||||
return $this->render('table/structure.html.twig', [
|
||||
'tableName' => $name,
|
||||
'tables' => $tables,
|
||||
'desc' => $desc,
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Query / Scan builder ──────────────────────────────────────────────────
|
||||
|
||||
#[Route('/{name}/query', name: 'table_query', methods: ['GET', 'POST'])]
|
||||
public function query(string $name, Request $request): Response
|
||||
{
|
||||
if ($r = $this->requireAuth($request)) return $r;
|
||||
|
||||
$tables = $this->dynamo->listTables();
|
||||
$keySchema = $this->dynamo->getTableKeySchema($name);
|
||||
$results = null;
|
||||
$error = null;
|
||||
$mode = 'query'; // or 'scan'
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$mode = $request->request->get('mode', 'query');
|
||||
try {
|
||||
if ($mode === 'scan') {
|
||||
$data = $this->dynamo->scanTable($name, null, 100);
|
||||
$results = $data['items'];
|
||||
} else {
|
||||
$conditions = [];
|
||||
$keys = $request->request->all('cond_key');
|
||||
$ops = $request->request->all('cond_op');
|
||||
$vals = $request->request->all('cond_val');
|
||||
|
||||
foreach ($keys as $i => $key) {
|
||||
if (!empty($key) && isset($vals[$i])) {
|
||||
$conditions[] = [
|
||||
'key' => $key,
|
||||
'op' => $ops[$i] ?? '=',
|
||||
'value' => $vals[$i],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$data = $this->dynamo->queryTable($name, $conditions, null, 100);
|
||||
$results = $data['items'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('table/query.html.twig', [
|
||||
'tableName' => $name,
|
||||
'tables' => $tables,
|
||||
'keySchema' => $keySchema,
|
||||
'results' => $results,
|
||||
'error' => $error,
|
||||
'mode' => $mode,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── New Item ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/{name}/item/new', name: 'table_item_new', methods: ['GET', 'POST'])]
|
||||
public function newItem(string $name, Request $request): Response
|
||||
{
|
||||
if ($r = $this->requireAuth($request)) return $r;
|
||||
|
||||
$tables = $this->dynamo->listTables();
|
||||
$keySchema = $this->dynamo->getTableKeySchema($name);
|
||||
$error = null;
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$json = $request->request->get('item_json', '{}');
|
||||
try {
|
||||
$item = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->dynamo->putItem($name, $item);
|
||||
$request->getSession()->set('toast', ['type' => 'success', 'msg' => 'Item inserted successfully.']);
|
||||
return $this->redirectToRoute('table_browse', ['name' => $name]);
|
||||
} catch (\Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('table/item_form.html.twig', [
|
||||
'tableName' => $name,
|
||||
'tables' => $tables,
|
||||
'keySchema' => $keySchema,
|
||||
'item' => null,
|
||||
'error' => $error,
|
||||
'mode' => 'new',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Edit Item ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/{name}/item/edit', name: 'table_item_edit', methods: ['GET', 'POST'])]
|
||||
public function editItem(string $name, Request $request): Response
|
||||
{
|
||||
if ($r = $this->requireAuth($request)) return $r;
|
||||
|
||||
$tables = $this->dynamo->listTables();
|
||||
$keySchema = $this->dynamo->getTableKeySchema($name);
|
||||
$error = null;
|
||||
|
||||
// Key passed as JSON in query string
|
||||
$keyJson = $request->query->get('key', '{}');
|
||||
$key = json_decode($keyJson, true) ?? [];
|
||||
|
||||
$item = null;
|
||||
if ($request->isMethod('GET')) {
|
||||
try {
|
||||
$item = $this->dynamo->getItem($name, $key);
|
||||
} catch (\Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$json = $request->request->get('item_json', '{}');
|
||||
try {
|
||||
$item = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->dynamo->putItem($name, $item);
|
||||
$request->getSession()->set('toast', ['type' => 'success', 'msg' => 'Item updated successfully.']);
|
||||
return $this->redirectToRoute('table_browse', ['name' => $name]);
|
||||
} catch (\Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
$item = json_decode($json, true);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('table/item_form.html.twig', [
|
||||
'tableName' => $name,
|
||||
'tables' => $tables,
|
||||
'keySchema' => $keySchema,
|
||||
'item' => $item,
|
||||
'error' => $error,
|
||||
'mode' => 'edit',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Delete Item ───────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/{name}/item/delete', name: 'table_item_delete', methods: ['POST'])]
|
||||
public function deleteItem(string $name, Request $request): Response
|
||||
{
|
||||
if ($r = $this->requireAuth($request)) return $r;
|
||||
|
||||
$keyJson = $request->request->get('key', '{}');
|
||||
$key = json_decode($keyJson, true) ?? [];
|
||||
|
||||
try {
|
||||
$this->dynamo->deleteItem($name, $key);
|
||||
$request->getSession()->set('toast', ['type' => 'success', 'msg' => 'Item deleted.']);
|
||||
} catch (\Exception $e) {
|
||||
$request->getSession()->set('toast', ['type' => 'error', 'msg' => 'Delete failed: ' . $e->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('table_browse', ['name' => $name]);
|
||||
}
|
||||
|
||||
// ── Create Table ──────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/create', name: 'table_create', methods: ['GET', 'POST'])]
|
||||
public function createTable(Request $request): Response
|
||||
{
|
||||
if ($r = $this->requireAuth($request)) return $r;
|
||||
|
||||
$tables = $this->dynamo->listTables();
|
||||
$error = null;
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$tName = $request->request->get('table_name');
|
||||
$pkName = $request->request->get('pk_name');
|
||||
$pkType = $request->request->get('pk_type', 'S');
|
||||
$skName = $request->request->get('sk_name') ?: null;
|
||||
$skType = $request->request->get('sk_type', 'S') ?: null;
|
||||
|
||||
try {
|
||||
$this->dynamo->createTable($tName, $pkName, $pkType, $skName, $skType);
|
||||
$request->getSession()->set('toast', ['type' => 'success', 'msg' => "Table \"$tName\" created."]);
|
||||
return $this->redirectToRoute('table_browse', ['name' => $tName]);
|
||||
} catch (\Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('table/create.html.twig', [
|
||||
'tables' => $tables,
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Drop Table ────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/{name}/drop', name: 'table_drop', methods: ['POST'])]
|
||||
public function dropTable(string $name, Request $request): Response
|
||||
{
|
||||
if ($r = $this->requireAuth($request)) return $r;
|
||||
|
||||
try {
|
||||
$this->dynamo->deleteTable($name);
|
||||
$request->getSession()->set('toast', ['type' => 'success', 'msg' => "Table \"$name\" dropped."]);
|
||||
} catch (\Exception $e) {
|
||||
$request->getSession()->set('toast', ['type' => 'error', 'msg' => 'Drop failed: ' . $e->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('dashboard');
|
||||
}
|
||||
}
|
||||
11
src/Kernel.php
Normal file
11
src/Kernel.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
}
|
||||
222
src/Service/DynamoService.php
Normal file
222
src/Service/DynamoService.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Aws\DynamoDb\DynamoDbClient;
|
||||
use Aws\DynamoDb\Marshaler;
|
||||
|
||||
class DynamoService
|
||||
{
|
||||
private DynamoDbClient $client;
|
||||
private Marshaler $marshaler;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->client = new DynamoDbClient([
|
||||
'region' => $_ENV['DYNAMO_REGION'] ?? 'us-east-1',
|
||||
'version' => 'latest',
|
||||
'endpoint' => $_ENV['DYNAMO_ENDPOINT'] ?? 'http://127.0.0.1:8002',
|
||||
'credentials' => [
|
||||
'key' => $_ENV['DYNAMO_KEY'] ?? 'AKIAIOSFODNN7EXAMPLE',
|
||||
'secret' => $_ENV['DYNAMO_SECRET'] ?? 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->marshaler = new Marshaler();
|
||||
}
|
||||
|
||||
// ── Tables ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function listTables(): array
|
||||
{
|
||||
$tables = [];
|
||||
$lastKey = null;
|
||||
|
||||
do {
|
||||
$params = ['Limit' => 100];
|
||||
if ($lastKey) {
|
||||
$params['ExclusiveStartTableName'] = $lastKey;
|
||||
}
|
||||
$result = $this->client->listTables($params);
|
||||
$tables = array_merge($tables, $result['TableNames'] ?? []);
|
||||
$lastKey = $result['LastEvaluatedTableName'] ?? null;
|
||||
} while ($lastKey);
|
||||
|
||||
return $tables;
|
||||
}
|
||||
|
||||
public function describeTable(string $name): array
|
||||
{
|
||||
$result = $this->client->describeTable(['TableName' => $name]);
|
||||
return $result['Table'];
|
||||
}
|
||||
|
||||
public function createTable(string $name, string $pkName, string $pkType, ?string $skName = null, ?string $skType = null): array
|
||||
{
|
||||
$keySchema = [['AttributeName' => $pkName, 'KeyType' => 'HASH']];
|
||||
$attrDefs = [['AttributeName' => $pkName, 'AttributeType' => $pkType]];
|
||||
|
||||
if ($skName && $skType) {
|
||||
$keySchema[] = ['AttributeName' => $skName, 'KeyType' => 'RANGE'];
|
||||
$attrDefs[] = ['AttributeName' => $skName, 'AttributeType' => $skType];
|
||||
}
|
||||
|
||||
$result = $this->client->createTable([
|
||||
'TableName' => $name,
|
||||
'KeySchema' => $keySchema,
|
||||
'AttributeDefinitions' => $attrDefs,
|
||||
'BillingMode' => 'PAY_PER_REQUEST',
|
||||
]);
|
||||
|
||||
return $result['TableDescription'];
|
||||
}
|
||||
|
||||
public function deleteTable(string $name): void
|
||||
{
|
||||
$this->client->deleteTable(['TableName' => $name]);
|
||||
}
|
||||
|
||||
// ── Items ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan with optional pagination. Returns [items[], lastKey|null, scannedCount]
|
||||
*/
|
||||
public function scanTable(string $table, ?array $lastKey = null, int $limit = 25): array
|
||||
{
|
||||
$params = [
|
||||
'TableName' => $table,
|
||||
'Limit' => $limit,
|
||||
];
|
||||
|
||||
if ($lastKey) {
|
||||
$params['ExclusiveStartKey'] = $this->marshaler->marshalItem($lastKey);
|
||||
}
|
||||
|
||||
$result = $this->client->scan($params);
|
||||
|
||||
$items = array_map(
|
||||
fn($item) => $this->marshaler->unmarshalItem($item),
|
||||
$result['Items'] ?? []
|
||||
);
|
||||
|
||||
$nextKey = null;
|
||||
if (!empty($result['LastEvaluatedKey'])) {
|
||||
$nextKey = $this->marshaler->unmarshalItem($result['LastEvaluatedKey']);
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'nextKey' => $nextKey,
|
||||
'scannedCount' => $result['ScannedCount'] ?? 0,
|
||||
'count' => $result['Count'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query by partition key (and optional sort key condition)
|
||||
*/
|
||||
public function queryTable(string $table, array $conditions, ?array $lastKey = null, int $limit = 25): array
|
||||
{
|
||||
$expression = [];
|
||||
$names = [];
|
||||
$values = [];
|
||||
|
||||
foreach ($conditions as $i => $cond) {
|
||||
$nameAlias = '#k' . $i;
|
||||
$valueAlias = ':v' . $i;
|
||||
$names[$nameAlias] = $cond['key'];
|
||||
$values[$valueAlias] = $this->marshaler->marshalValue($cond['value']);
|
||||
|
||||
$op = match($cond['op'] ?? '=') {
|
||||
'begins_with' => "begins_with($nameAlias, $valueAlias)",
|
||||
'between' => "$nameAlias BETWEEN $valueAlias AND :v{$i}b",
|
||||
'<' => "$nameAlias < $valueAlias",
|
||||
'<=' => "$nameAlias <= $valueAlias",
|
||||
'>' => "$nameAlias > $valueAlias",
|
||||
'>=' => "$nameAlias >= $valueAlias",
|
||||
default => "$nameAlias = $valueAlias",
|
||||
};
|
||||
|
||||
$expression[] = $op;
|
||||
}
|
||||
|
||||
$params = [
|
||||
'TableName' => $table,
|
||||
'KeyConditionExpression' => implode(' AND ', $expression),
|
||||
'ExpressionAttributeNames' => $names,
|
||||
'ExpressionAttributeValues' => $values,
|
||||
'Limit' => $limit,
|
||||
];
|
||||
|
||||
if ($lastKey) {
|
||||
$params['ExclusiveStartKey'] = $this->marshaler->marshalItem($lastKey);
|
||||
}
|
||||
|
||||
$result = $this->client->query($params);
|
||||
|
||||
$items = array_map(
|
||||
fn($item) => $this->marshaler->unmarshalItem($item),
|
||||
$result['Items'] ?? []
|
||||
);
|
||||
|
||||
$nextKey = null;
|
||||
if (!empty($result['LastEvaluatedKey'])) {
|
||||
$nextKey = $this->marshaler->unmarshalItem($result['LastEvaluatedKey']);
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'nextKey' => $nextKey,
|
||||
'scannedCount' => $result['ScannedCount'] ?? 0,
|
||||
'count' => $result['Count'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function getItem(string $table, array $key): ?array
|
||||
{
|
||||
$result = $this->client->getItem([
|
||||
'TableName' => $table,
|
||||
'Key' => $this->marshaler->marshalItem($key),
|
||||
]);
|
||||
|
||||
if (empty($result['Item'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->marshaler->unmarshalItem($result['Item']);
|
||||
}
|
||||
|
||||
public function putItem(string $table, array $item): void
|
||||
{
|
||||
$this->client->putItem([
|
||||
'TableName' => $table,
|
||||
'Item' => $this->marshaler->marshalItem($item),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteItem(string $table, array $key): void
|
||||
{
|
||||
$this->client->deleteItem([
|
||||
'TableName' => $table,
|
||||
'Key' => $this->marshaler->marshalItem($key),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
public function getTableKeySchema(string $table): array
|
||||
{
|
||||
$desc = $this->describeTable($table);
|
||||
$schema = [];
|
||||
foreach ($desc['KeySchema'] as $k) {
|
||||
$type = 'S';
|
||||
foreach ($desc['AttributeDefinitions'] as $attr) {
|
||||
if ($attr['AttributeName'] === $k['AttributeName']) {
|
||||
$type = $attr['AttributeType'];
|
||||
}
|
||||
}
|
||||
$schema[$k['KeyType']] = ['name' => $k['AttributeName'], 'type' => $type];
|
||||
}
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
112
symfony.lock
Normal file
112
symfony.lock
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"symfony/console": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.3",
|
||||
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
||||
},
|
||||
"files": [
|
||||
"bin/console"
|
||||
]
|
||||
},
|
||||
"symfony/flex": {
|
||||
"version": "2.10",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.4",
|
||||
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
|
||||
},
|
||||
"files": [
|
||||
".env",
|
||||
".env.dev"
|
||||
]
|
||||
},
|
||||
"symfony/form": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.2",
|
||||
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/csrf.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/framework-bundle": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "09f6e081c763a206802674ce0cb34a022f0ffc6d"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/cache.yaml",
|
||||
"config/packages/framework.yaml",
|
||||
"config/preload.php",
|
||||
"config/routes/framework.yaml",
|
||||
"config/services.yaml",
|
||||
"public/index.php",
|
||||
"src/Controller/.gitignore",
|
||||
"src/Kernel.php",
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.3",
|
||||
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/property_info.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/routing.yaml",
|
||||
"config/routes.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/twig-bundle": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.4",
|
||||
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/twig.yaml",
|
||||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"symfony/validator": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.0",
|
||||
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/validator.yaml"
|
||||
]
|
||||
},
|
||||
"twig/extra-bundle": {
|
||||
"version": "v3.23.0"
|
||||
}
|
||||
}
|
||||
12
templates/_sidebar_tables.html.twig
Normal file
12
templates/_sidebar_tables.html.twig
Normal file
@@ -0,0 +1,12 @@
|
||||
{% macro sidebar_table_links(tables, activeName) %}
|
||||
{% for table in tables %}
|
||||
<a
|
||||
href="{{ path('table_browse', {name: table}) }}"
|
||||
class="sidebar-table-link {% if table == activeName %}active{% endif %}"
|
||||
x-show="filterMatches('{{ table }}')"
|
||||
>
|
||||
<i class="bi bi-table"></i>
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ table }}">{{ table }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
175
templates/auth/login.html.twig
Normal file
175
templates/auth/login.html.twig
Normal file
@@ -0,0 +1,175 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Jormun Admin — Sign In</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--jormun-teal: #0d9488;
|
||||
--jormun-teal-mid: #14b8a6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f0fdfa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
/* Subtle background grid */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(13,148,136,0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(13,148,136,0.04) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
width: 380px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.login-logo svg { width: 52px; height: 52px; }
|
||||
.login-logo h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
color: var(--jormun-teal);
|
||||
margin: 0.4rem 0 0.1rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.login-logo p {
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 24px rgba(13,148,136,0.06);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.form-control {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 5px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: var(--jormun-teal);
|
||||
box-shadow: 0 0 0 3px rgba(13,148,136,0.12);
|
||||
}
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
background: var(--jormun-teal);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition: background 0.12s;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.btn-login:hover { background: #059669; }
|
||||
|
||||
.alert-error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
color: #991b1b;
|
||||
border-radius: 5px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.68rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrap">
|
||||
<div class="login-logo">
|
||||
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 3C9.373 3 4 8.373 4 15c0 4.5 2.3 8.46 5.78 10.8" stroke="#0d9488" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M16 29c6.627 0 12-5.373 12-12 0-4.5-2.3-8.46-5.78-10.8" stroke="#14b8a6" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M9.78 25.8C11.5 27.2 13.65 28 16 28" stroke="#0d9488" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M22.22 6.2C20.5 4.8 18.35 4 16 4" stroke="#14b8a6" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="9" cy="26" r="2" fill="#0d9488"/>
|
||||
<circle cx="23" cy="6" r="2" fill="#14b8a6"/>
|
||||
</svg>
|
||||
<h1>JormunAdmin</h1>
|
||||
<p>DynamoDB-compatible database administration</p>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
{% if error %}
|
||||
<div class="alert-error">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ path('login') }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" name="username" class="form-control" placeholder="root" autofocus required>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="password" class="form-control" placeholder="••••••" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
Jormun Admin — JormunDB Administration Interface
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
628
templates/base.html.twig
Normal file
628
templates/base.html.twig
Normal file
@@ -0,0 +1,628 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Jormun Admin{% endblock %}</title>
|
||||
|
||||
{# Bootstrap 5 - modern, light/dark built-in #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
{# Bootstrap Icons #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
{# Google Fonts: Syne (display) + DM Mono (data) #}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--jormun-teal: #0d9488;
|
||||
--jormun-teal-mid: #14b8a6;
|
||||
--jormun-teal-light: #ccfbf1;
|
||||
--jormun-teal-xlight: #f0fdfa;
|
||||
--jormun-dark: #0f2027;
|
||||
--jormun-accent: #059669;
|
||||
|
||||
--sidebar-width: 230px;
|
||||
--topbar-height: 52px;
|
||||
}
|
||||
|
||||
/* ── Typography ── */
|
||||
body {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.82rem;
|
||||
background: #f8fafb;
|
||||
}
|
||||
.font-display { font-family: 'Syne', sans-serif; }
|
||||
|
||||
/* ── Topbar ── */
|
||||
.jormun-topbar {
|
||||
height: var(--topbar-height);
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 1030;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
.jormun-logo {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
color: var(--jormun-teal);
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.jormun-logo .logo-serpent {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.jormun-logo span.sub {
|
||||
font-weight: 400;
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
/* ── Breadcrumbs ── */
|
||||
.jormun-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
flex: 1;
|
||||
}
|
||||
.jormun-breadcrumb a {
|
||||
color: var(--jormun-teal);
|
||||
text-decoration: none;
|
||||
}
|
||||
.jormun-breadcrumb a:hover { text-decoration: underline; }
|
||||
.jormun-breadcrumb .sep { color: #cbd5e1; }
|
||||
.jormun-breadcrumb .current {
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.jormun-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: #fff;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
position: fixed;
|
||||
top: var(--topbar-height);
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
z-index: 900;
|
||||
transition: transform 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.jormun-sidebar.collapsed {
|
||||
transform: translateX(calc(-1 * var(--sidebar-width)));
|
||||
}
|
||||
|
||||
.sidebar-section-label {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
padding: 0.75rem 1rem 0.3rem;
|
||||
}
|
||||
|
||||
.sidebar-table-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.38rem 1rem;
|
||||
color: #374151;
|
||||
text-decoration: none;
|
||||
font-size: 0.78rem;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.sidebar-table-link:hover {
|
||||
background: var(--jormun-teal-xlight);
|
||||
color: var(--jormun-teal);
|
||||
border-left-color: var(--jormun-teal-mid);
|
||||
}
|
||||
.sidebar-table-link.active {
|
||||
background: var(--jormun-teal-light);
|
||||
color: var(--jormun-teal);
|
||||
border-left-color: var(--jormun-teal);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sidebar-table-link i { font-size: 0.75rem; opacity: 0.6; }
|
||||
|
||||
.sidebar-new-table {
|
||||
margin: 0.5rem 0.75rem;
|
||||
}
|
||||
.sidebar-search {
|
||||
padding: 0.5rem 0.75rem 0.3rem;
|
||||
}
|
||||
|
||||
/* ── Main content ── */
|
||||
.jormun-main {
|
||||
margin-left: var(--sidebar-width);
|
||||
margin-top: var(--topbar-height);
|
||||
padding: 1.25rem 1.5rem;
|
||||
min-height: calc(100vh - var(--topbar-height));
|
||||
transition: margin-left 0.2s ease;
|
||||
}
|
||||
.jormun-main.sidebar-collapsed {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* ── Operation Tabs ── */
|
||||
.op-tabs {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.op-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.78rem;
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.op-tab:hover {
|
||||
color: var(--jormun-teal);
|
||||
background: var(--jormun-teal-xlight);
|
||||
}
|
||||
.op-tab.active {
|
||||
color: var(--jormun-teal);
|
||||
border-bottom-color: var(--jormun-teal);
|
||||
font-weight: 500;
|
||||
}
|
||||
.op-tab i { font-size: 0.8rem; }
|
||||
|
||||
/* ── Toasts ── */
|
||||
.toast-zone {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
.jormun-toast {
|
||||
pointer-events: all;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.10);
|
||||
border-left: 4px solid;
|
||||
background: #fff;
|
||||
min-width: 260px;
|
||||
max-width: 400px;
|
||||
animation: toast-in 0.22s ease;
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.jormun-toast.success { border-color: var(--jormun-teal); }
|
||||
.jormun-toast.success i { color: var(--jormun-teal); }
|
||||
.jormun-toast.error { border-color: #ef4444; }
|
||||
.jormun-toast.error i { color: #ef4444; }
|
||||
.jormun-toast.info { border-color: #3b82f6; }
|
||||
.jormun-toast.info i { color: #3b82f6; }
|
||||
|
||||
/* ── Content card ── */
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.content-card-header {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.content-card-header h6 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Data table ── */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.data-table th {
|
||||
background: #f8fafc;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-table td {
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: top;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-table tr:hover td { background: var(--jormun-teal-xlight); }
|
||||
.data-table .pk-cell {
|
||||
color: var(--jormun-teal);
|
||||
font-weight: 500;
|
||||
}
|
||||
.data-table .null-val { color: #cbd5e1; font-style: italic; }
|
||||
.data-table .action-cell { white-space: nowrap; }
|
||||
|
||||
/* ── Badges / pills ── */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.status-badge.active { background: #dcfce7; color: #166534; }
|
||||
.status-badge.creating { background: #fef9c3; color: #854d0e; }
|
||||
.status-badge.deleting { background: #fee2e2; color: #991b1b; }
|
||||
.status-badge.error { background: #fee2e2; color: #991b1b; }
|
||||
.status-badge.unknown { background: #f1f5f9; color: #64748b; }
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
background: var(--jormun-teal-light);
|
||||
color: var(--jormun-teal);
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn-jormun {
|
||||
background: var(--jormun-teal);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.38rem 0.9rem;
|
||||
border-radius: 5px;
|
||||
font-size: 0.78rem;
|
||||
font-family: 'DM Mono', monospace;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.btn-jormun:hover { background: var(--jormun-accent); color: #fff; }
|
||||
.btn-jormun-outline {
|
||||
background: transparent;
|
||||
color: var(--jormun-teal);
|
||||
border: 1px solid var(--jormun-teal);
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 5px;
|
||||
font-size: 0.78rem;
|
||||
font-family: 'DM Mono', monospace;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.btn-jormun-outline:hover {
|
||||
background: var(--jormun-teal);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger-sm {
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
border: 1px solid #fca5a5;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
font-family: 'DM Mono', monospace;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.btn-danger-sm:hover { background: #ef4444; color: #fff; }
|
||||
.btn-sm-edit {
|
||||
background: transparent;
|
||||
color: #3b82f6;
|
||||
border: 1px solid #bfdbfe;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
font-family: 'DM Mono', monospace;
|
||||
text-decoration: none;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.btn-sm-edit:hover { background: #3b82f6; color: #fff; }
|
||||
|
||||
/* ── Form elements ── */
|
||||
.jormun-input {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.65rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 5px;
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: #1e293b;
|
||||
background: #fff;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.jormun-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--jormun-teal);
|
||||
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.12);
|
||||
}
|
||||
.jormun-select {
|
||||
padding: 0.4rem 0.65rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 5px;
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: #1e293b;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── JSON editor ── */
|
||||
.json-editor {
|
||||
width: 100%;
|
||||
min-height: 280px;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 5px;
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: #1e293b;
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.json-editor:focus {
|
||||
outline: none;
|
||||
border-color: var(--jormun-teal);
|
||||
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.12);
|
||||
}
|
||||
|
||||
/* ── Dark mode overrides ── */
|
||||
[data-bs-theme="dark"] body { background: #0f172a; }
|
||||
[data-bs-theme="dark"] .jormun-topbar,
|
||||
[data-bs-theme="dark"] .jormun-sidebar {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
[data-bs-theme="dark"] .content-card {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
[data-bs-theme="dark"] .content-card-header {
|
||||
background: #0f172a;
|
||||
border-color: #334155;
|
||||
}
|
||||
[data-bs-theme="dark"] .data-table th {
|
||||
background: #0f172a;
|
||||
color: #94a3b8;
|
||||
}
|
||||
[data-bs-theme="dark"] .data-table td { border-color: #1e293b; }
|
||||
[data-bs-theme="dark"] .sidebar-table-link { color: #94a3b8; }
|
||||
[data-bs-theme="dark"] .jormun-input,
|
||||
[data-bs-theme="dark"] .jormun-select,
|
||||
[data-bs-theme="dark"] .json-editor {
|
||||
background: #0f172a;
|
||||
border-color: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
[data-bs-theme="dark"] .jormun-toast { background: #1e293b; }
|
||||
|
||||
/* ── Misc ── */
|
||||
.cell-value { font-family: 'DM Mono', monospace; }
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.empty-state i { font-size: 2.5rem; display: block; margin-bottom: 0.75rem; color: #cbd5e1; }
|
||||
.stat-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
background: #f1f5f9;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Sidebar toggle btn */
|
||||
.sidebar-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.sidebar-toggle:hover { background: #f1f5f9; color: var(--jormun-teal); }
|
||||
</style>
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body x-data="jormunApp()" :data-bs-theme="darkMode ? 'dark' : 'light'">
|
||||
|
||||
{# ── Topbar ── #}
|
||||
<header class="jormun-topbar">
|
||||
<button class="sidebar-toggle" @click="sidebarOpen = !sidebarOpen" title="Toggle sidebar">
|
||||
<i class="bi bi-layout-sidebar"></i>
|
||||
</button>
|
||||
|
||||
<a href="{{ path('dashboard') }}" class="jormun-logo">
|
||||
{# Serpent SVG icon — stylized ouroboros/coil #}
|
||||
<svg class="logo-serpent" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 3C9.373 3 4 8.373 4 15c0 4.5 2.3 8.46 5.78 10.8" stroke="#0d9488" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M16 29c6.627 0 12-5.373 12-12 0-4.5-2.3-8.46-5.78-10.8" stroke="#14b8a6" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M9.78 25.8C11.5 27.2 13.65 28 16 28" stroke="#0d9488" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M22.22 6.2C20.5 4.8 18.35 4 16 4" stroke="#14b8a6" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="9" cy="26" r="2" fill="#0d9488"/>
|
||||
<circle cx="23" cy="6" r="2" fill="#14b8a6"/>
|
||||
</svg>
|
||||
Jormun<span style="color:var(--jormun-teal-mid)">Admin</span>
|
||||
<span class="sub">JormunDB</span>
|
||||
</a>
|
||||
|
||||
{# Breadcrumbs #}
|
||||
<nav class="jormun-breadcrumb">
|
||||
{% block breadcrumbs %}
|
||||
<a href="{{ path('dashboard') }}"><i class="bi bi-house"></i> Home</a>
|
||||
{% endblock %}
|
||||
</nav>
|
||||
|
||||
{# Right side controls #}
|
||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||
<button class="sidebar-toggle" @click="toggleDark()" :title="darkMode ? 'Light mode' : 'Dark mode'">
|
||||
<i :class="darkMode ? 'bi bi-sun' : 'bi bi-moon'"></i>
|
||||
</button>
|
||||
<a href="{{ path('logout') }}" class="sidebar-toggle" title="Logout">
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# ── Sidebar ── #}
|
||||
<aside class="jormun-sidebar" :class="{ 'collapsed': !sidebarOpen }">
|
||||
<div class="sidebar-search">
|
||||
<input
|
||||
type="text"
|
||||
class="jormun-input"
|
||||
placeholder=" Filter tables…"
|
||||
x-model="tableFilter"
|
||||
style="font-family: 'DM Mono', monospace; font-size:0.75rem;"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section-label">Tables</div>
|
||||
|
||||
{% block sidebar_tables %}{% endblock %}
|
||||
|
||||
<div style="padding: 0.5rem 0.75rem; margin-top: auto; border-top: 1px solid #f1f5f9;">
|
||||
<a href="{{ path('table_create') }}" class="btn-jormun-outline w-100" style="justify-content:center;">
|
||||
<i class="bi bi-plus-lg"></i> New Table
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{# ── Main ── #}
|
||||
<main class="jormun-main" :class="{ 'sidebar-collapsed': !sidebarOpen }">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{# ── Toast Zone ── #}
|
||||
<div class="toast-zone" x-data>
|
||||
<template x-for="(t, i) in $store.toasts.list" :key="t.id">
|
||||
<div class="jormun-toast" :class="t.type" x-show="t.visible" x-transition.opacity>
|
||||
<i :class="{
|
||||
'bi bi-check-circle-fill': t.type === 'success',
|
||||
'bi bi-exclamation-triangle-fill': t.type === 'error',
|
||||
'bi bi-info-circle-fill': t.type === 'info'
|
||||
}"></i>
|
||||
<span x-text="t.msg" style="flex:1;"></span>
|
||||
<button @click="$store.toasts.dismiss(t.id)" style="background:none;border:none;cursor:pointer;color:#94a3b8;padding:0 0.2rem;">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# ── Server-side toast injection ── #}
|
||||
{% if app.session.get('toast') %}
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
const t = {{ app.session.get('toast')|json_encode|raw }};
|
||||
Alpine.store('toasts').add(t.type, t.msg);
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// ── Toast store ──
|
||||
Alpine.store('toasts', {
|
||||
list: [],
|
||||
_id: 0,
|
||||
add(type, msg, duration = 4000) {
|
||||
const id = ++this._id;
|
||||
this.list.push({ id, type, msg, visible: true });
|
||||
setTimeout(() => this.dismiss(id), duration);
|
||||
},
|
||||
dismiss(id) {
|
||||
const t = this.list.find(x => x.id === id);
|
||||
if (t) t.visible = false;
|
||||
setTimeout(() => { this.list = this.list.filter(x => x.id !== id); }, 300);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function jormunApp() {
|
||||
return {
|
||||
sidebarOpen: true,
|
||||
tableFilter: '',
|
||||
darkMode: localStorage.getItem('jormun-dark') === '1',
|
||||
|
||||
toggleDark() {
|
||||
this.darkMode = !this.darkMode;
|
||||
localStorage.setItem('jormun-dark', this.darkMode ? '1' : '0');
|
||||
},
|
||||
|
||||
filterMatches(name) {
|
||||
if (!this.tableFilter) return true;
|
||||
return name.toLowerCase().includes(this.tableFilter.toLowerCase());
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
112
templates/dashboard/index.html.twig
Normal file
112
templates/dashboard/index.html.twig
Normal file
@@ -0,0 +1,112 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Jormun Admin — Dashboard{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
|
||||
<span class="sep">/</span>
|
||||
<span class="current">Dashboard</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar_tables %}
|
||||
{% for table in tables %}
|
||||
<a
|
||||
href="{{ path('table_browse', {name: table.name}) }}"
|
||||
class="sidebar-table-link"
|
||||
x-show="filterMatches('{{ table.name }}')"
|
||||
>
|
||||
<i class="bi bi-table"></i>
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ table.name }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h5 class="font-display mb-0" style="font-weight:700;font-size:1rem;">Dashboard</h5>
|
||||
<div style="font-size:0.72rem;color:#64748b;">{{ tables|length }} table{{ tables|length != 1 ? 's' : '' }} in instance</div>
|
||||
</div>
|
||||
<a href="{{ path('table_create') }}" class="btn-jormun">
|
||||
<i class="bi bi-plus-lg"></i> New Table
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.8rem;">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tables is empty and not error %}
|
||||
<div class="content-card">
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-database-slash"></i>
|
||||
<div style="font-family:'Syne',sans-serif;font-weight:700;font-size:0.9rem;color:#374151;margin-bottom:0.4rem;">No tables yet</div>
|
||||
<div style="font-size:0.75rem;margin-bottom:1rem;">Create your first JormunDB table to get started.</div>
|
||||
<a href="{{ path('table_create') }}" class="btn-jormun">
|
||||
<i class="bi bi-plus-lg"></i> Create Table
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="content-card">
|
||||
<div class="content-card-header">
|
||||
<h6><i class="bi bi-database me-1" style="color:var(--jormun-teal);"></i> Tables</h6>
|
||||
<span class="stat-pill"><i class="bi bi-layers"></i> {{ tables|length }} total</span>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table Name</th>
|
||||
<th>Status</th>
|
||||
<th>Item Count</th>
|
||||
<th>Size</th>
|
||||
<th>Partition Key</th>
|
||||
<th>Sort Key</th>
|
||||
<th style="width:120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for table in tables %}
|
||||
{% set pk = null %}
|
||||
{% set sk = null %}
|
||||
{% for k in table.keySchema %}
|
||||
{% if k.KeyType == 'HASH' %}{% set pk = k.AttributeName %}{% endif %}
|
||||
{% if k.KeyType == 'RANGE' %}{% set sk = k.AttributeName %}{% endif %}
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ path('table_browse', {name: table.name}) }}" style="color:var(--jormun-teal);text-decoration:none;font-weight:500;">
|
||||
<i class="bi bi-table me-1" style="opacity:0.5;"></i>{{ table.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge {{ table.status|lower }}">{{ table.status }}</span>
|
||||
</td>
|
||||
<td>{{ table.itemCount|number_format }}</td>
|
||||
<td>
|
||||
{% if table.sizeBytes >= 1048576 %}
|
||||
{{ (table.sizeBytes / 1048576)|number_format(1) }} MB
|
||||
{% elseif table.sizeBytes >= 1024 %}
|
||||
{{ (table.sizeBytes / 1024)|number_format(1) }} KB
|
||||
{% else %}
|
||||
{{ table.sizeBytes }} B
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="type-badge">{{ pk ?? '—' }}</span></td>
|
||||
<td><span class="{% if sk %}type-badge{% else %}null-val{% endif %}">{{ sk ?? 'none' }}</span></td>
|
||||
<td class="action-cell">
|
||||
<a href="{{ path('table_browse', {name: table.name}) }}" class="btn-sm-edit">
|
||||
<i class="bi bi-eye"></i> Browse
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
240
templates/table/browse.html.twig
Normal file
240
templates/table/browse.html.twig
Normal file
@@ -0,0 +1,240 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% import '_sidebar_tables.html.twig' as macros %}
|
||||
|
||||
{% block title %}{{ tableName }} — Browse · Jormun Admin{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
|
||||
<span class="sep">/</span>
|
||||
<span class="current">{{ tableName }}</span>
|
||||
<span class="sep">/</span>
|
||||
<span class="current">Browse</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar_tables %}
|
||||
{{ macros.sidebar_table_links(tables, tableName) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# Op Tabs #}
|
||||
<nav class="op-tabs">
|
||||
<a href="{{ path('table_browse', {name: tableName}) }}" class="op-tab active">
|
||||
<i class="bi bi-grid-3x3-gap"></i> Browse
|
||||
</a>
|
||||
<a href="{{ path('table_structure', {name: tableName}) }}" class="op-tab">
|
||||
<i class="bi bi-diagram-3"></i> Structure
|
||||
</a>
|
||||
<a href="{{ path('table_query', {name: tableName}) }}" class="op-tab">
|
||||
<i class="bi bi-search"></i> Query
|
||||
</a>
|
||||
<a href="{{ path('table_item_new', {name: tableName}) }}" class="op-tab">
|
||||
<i class="bi bi-plus-circle"></i> Insert
|
||||
</a>
|
||||
<span style="flex:1;"></span>
|
||||
{# Drop table — in a confirmation modal #}
|
||||
<button class="op-tab" style="color:#ef4444;" @click="showDrop = true">
|
||||
<i class="bi bi-trash3"></i> Drop Table
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{# Error #}
|
||||
{% if error %}
|
||||
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.8rem;">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Stats row #}
|
||||
{% if not error %}
|
||||
<div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
|
||||
<span class="stat-pill"><i class="bi bi-table"></i> {{ tableName }}</span>
|
||||
<span class="stat-pill"><i class="bi bi-check-circle" style="color:var(--jormun-teal);"></i> {{ (desc.TableStatus ?? 'UNKNOWN')|lower }}</span>
|
||||
<span class="stat-pill"><i class="bi bi-collection"></i> ~{{ (desc.ItemCount ?? 0)|number_format }} items</span>
|
||||
<span class="stat-pill"><i class="bi bi-eye"></i> showing {{ count }} of {{ scannedCount }} scanned</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Main data card #}
|
||||
<div class="content-card" x-data="browseTable()">
|
||||
<div class="content-card-header">
|
||||
<h6><i class="bi bi-grid-3x3-gap me-1" style="color:var(--jormun-teal);"></i> Rows</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label style="font-size:0.72rem;color:#64748b;">Rows per page</label>
|
||||
<select class="jormun-select" onchange="changeLimit(this.value)" style="width:auto;">
|
||||
{% for l in [10, 25, 50, 100] %}
|
||||
<option value="{{ l }}" {% if l == limit %}selected{% endif %}>{{ l }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if items is empty %}
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<div style="font-family:'Syne',sans-serif;font-weight:700;font-size:0.9rem;color:#374151;margin-bottom:0.3rem;">No items found</div>
|
||||
<div style="font-size:0.75rem;">This table is empty.</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:100px;">Actions</th>
|
||||
{% for col in columns %}
|
||||
<th>
|
||||
{{ col }}
|
||||
{% if keySchema.HASH is defined and keySchema.HASH.name == col %}
|
||||
<span class="type-badge" title="Partition Key">PK</span>
|
||||
{% endif %}
|
||||
{% if keySchema.RANGE is defined and keySchema.RANGE.name == col %}
|
||||
<span class="type-badge" title="Sort Key">SK</span>
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
{% set itemKey = {} %}
|
||||
{% if keySchema.HASH is defined %}
|
||||
{% set itemKey = itemKey|merge({(keySchema.HASH.name): item[keySchema.HASH.name]}) %}
|
||||
{% endif %}
|
||||
{% if keySchema.RANGE is defined %}
|
||||
{% set itemKey = itemKey|merge({(keySchema.RANGE.name): item[keySchema.RANGE.name]}) %}
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td class="action-cell">
|
||||
<a href="{{ path('table_item_edit', {name: tableName, key: itemKey|json_encode}) }}" class="btn-sm-edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<button
|
||||
class="btn-danger-sm ms-1"
|
||||
@click="confirmDelete($el.dataset.key)"
|
||||
data-key="{{ itemKey|json_encode|e('html_attr') }}"
|
||||
>
|
||||
<i class="bi bi-trash3"></i>
|
||||
</button>
|
||||
</td>
|
||||
{% for col in columns %}
|
||||
<td class="{% if keySchema.HASH is defined and keySchema.HASH.name == col %}pk-cell{% endif %} cell-value" title="{{ item[col] is defined ? item[col]|json_encode : 'null' }}">
|
||||
{% if item[col] is defined %}
|
||||
{% set val = item[col] %}
|
||||
{% if val is iterable %}
|
||||
<span style="color:#6366f1;" title="{{ val|json_encode }}">
|
||||
<i class="bi bi-braces"></i> {{ val|json_encode|slice(0, 40) }}{% if val|json_encode|length > 40 %}…{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
{{ val|e|slice(0, 60) }}{% if (val|e|length) > 60 %}…{% endif %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="null-val">NULL</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Pagination #}
|
||||
<div style="padding:0.65rem 1rem;border-top:1px solid #f1f5f9;display:flex;align-items:center;gap:0.5rem;justify-content:space-between;">
|
||||
<div style="font-size:0.72rem;color:#64748b;">
|
||||
Showing {{ count }} item{{ count != 1 ? 's' : '' }}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if prevKeys is not empty %}
|
||||
{% set prevStack = prevKeys[0:(prevKeys|length - 1)] %}
|
||||
{% set prevLastKey = prevKeys|last %}
|
||||
<a href="{{ path('table_browse', {name: tableName, lastKey: prevLastKey, prevKeys: prevStack|json_encode, limit: limit}) }}" class="btn-jormun-outline">
|
||||
<i class="bi bi-chevron-left"></i> Prev
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if nextKey %}
|
||||
{% set newPrevKeys = prevKeys %}
|
||||
{% if currentKey %}
|
||||
{% set newPrevKeys = newPrevKeys|merge([currentKey]) %}
|
||||
{% else %}
|
||||
{# first page, push empty marker #}
|
||||
{% set newPrevKeys = newPrevKeys|merge(['null']) %}
|
||||
{% endif %}
|
||||
<a href="{{ path('table_browse', {name: tableName, lastKey: nextKey, prevKeys: newPrevKeys|json_encode, limit: limit}) }}" class="btn-jormun">
|
||||
Next <i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Delete item modal #}
|
||||
<div x-show="deleteModal" x-cloak style="position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9000;display:flex;align-items:center;justify-content:center;" @click.self="deleteModal=false">
|
||||
<div style="background:#fff;border-radius:8px;padding:1.5rem;width:420px;max-width:90vw;" @click.stop>
|
||||
<h6 class="font-display" style="font-weight:700;margin-bottom:0.5rem;">
|
||||
<i class="bi bi-trash3-fill" style="color:#ef4444;"></i> Delete Item
|
||||
</h6>
|
||||
<p style="font-size:0.8rem;color:#64748b;margin-bottom:1rem;">This will permanently delete the item with key:</p>
|
||||
<pre style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:4px;padding:0.5rem;font-size:0.72rem;margin-bottom:1rem;" x-text="JSON.stringify(deleteKey, null, 2)"></pre>
|
||||
<div class="d-flex gap-2 justify-content-end">
|
||||
<button class="btn-jormun-outline" @click="deleteModal=false">Cancel</button>
|
||||
<form method="POST" :action="deleteAction">
|
||||
<input type="hidden" name="key" :value="JSON.stringify(deleteKey)">
|
||||
<button type="submit" class="btn-jormun" style="background:#ef4444;">
|
||||
<i class="bi bi-trash3"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Drop Table modal #}
|
||||
<div x-data="{ showDrop: false }" x-cloak>
|
||||
<div x-show="showDrop" style="position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9000;display:flex;align-items:center;justify-content:center;" @click.self="showDrop=false">
|
||||
<div style="background:#fff;border-radius:8px;padding:1.5rem;width:400px;max-width:90vw;">
|
||||
<h6 class="font-display" style="font-weight:700;margin-bottom:0.5rem;color:#ef4444;">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Drop Table
|
||||
</h6>
|
||||
<p style="font-size:0.8rem;color:#374151;margin-bottom:1rem;">
|
||||
Are you sure you want to drop <strong>{{ tableName }}</strong>? This action is <strong>irreversible</strong> and will delete all data.
|
||||
</p>
|
||||
<div class="d-flex gap-2 justify-content-end">
|
||||
<button class="btn-jormun-outline" @click="showDrop=false">Cancel</button>
|
||||
<form method="POST" action="{{ path('table_drop', {name: tableName}) }}">
|
||||
<button type="submit" class="btn-jormun" style="background:#ef4444;">
|
||||
<i class="bi bi-trash3-fill"></i> Drop Table
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function browseTable() {
|
||||
return {
|
||||
deleteModal: false,
|
||||
deleteKey: null,
|
||||
deleteAction: '',
|
||||
|
||||
confirmDelete(keyJson) {
|
||||
this.deleteKey = JSON.parse(keyJson);
|
||||
this.deleteAction = '{{ path('table_item_delete', {name: tableName}) }}';
|
||||
this.deleteModal = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function changeLimit(val) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('limit', val);
|
||||
url.searchParams.delete('lastKey');
|
||||
url.searchParams.delete('prevKeys');
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
104
templates/table/create.html.twig
Normal file
104
templates/table/create.html.twig
Normal file
@@ -0,0 +1,104 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% import '_sidebar_tables.html.twig' as macros %}
|
||||
|
||||
{% block title %}Create Table · Jormun Admin{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
|
||||
<span class="sep">/</span>
|
||||
<span class="current">Create Table</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar_tables %}
|
||||
{{ macros.sidebar_table_links(tables, null) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-7 col-xl-6">
|
||||
<div class="content-card">
|
||||
<div class="content-card-header">
|
||||
<h6><i class="bi bi-plus-square me-1" style="color:var(--jormun-teal);"></i> Create New Table</h6>
|
||||
</div>
|
||||
<div style="padding:1.25rem;">
|
||||
{% if error %}
|
||||
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.65rem 0.85rem;margin-bottom:1rem;font-size:0.78rem;">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" x-data="{ hasSk: false }">
|
||||
{# Table name #}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="font-size:0.72rem;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;color:#374151;">Table Name</label>
|
||||
<input type="text" name="table_name" class="jormun-input" placeholder="my-table" required
|
||||
pattern="[a-zA-Z0-9_.-]+" title="Letters, numbers, underscores, dots, hyphens">
|
||||
<div style="font-size:0.68rem;color:#94a3b8;margin-top:0.25rem;">Letters, numbers, hyphens, underscores, dots. 3–255 chars.</div>
|
||||
</div>
|
||||
|
||||
<hr style="border-color:#f1f5f9;margin:1rem 0;">
|
||||
|
||||
{# Partition key #}
|
||||
<div style="font-family:'Syne',sans-serif;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#374151;margin-bottom:0.75rem;">
|
||||
<i class="bi bi-key" style="color:var(--jormun-teal);"></i> Partition Key
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-8">
|
||||
<input type="text" name="pk_name" class="jormun-input" placeholder="id" required>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<select name="pk_type" class="jormun-select w-100">
|
||||
<option value="S">String (S)</option>
|
||||
<option value="N">Number (N)</option>
|
||||
<option value="B">Binary (B)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="border-color:#f1f5f9;margin:1rem 0;">
|
||||
|
||||
{# Sort key toggle #}
|
||||
<div class="mb-3">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;font-size:0.78rem;">
|
||||
<input type="checkbox" x-model="hasSk" style="accent-color:var(--jormun-teal);">
|
||||
Add Sort Key
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div x-show="hasSk" x-transition>
|
||||
<div style="font-family:'Syne',sans-serif;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#374151;margin-bottom:0.75rem;">
|
||||
<i class="bi bi-sort-down" style="color:var(--jormun-teal);"></i> Sort Key
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-8">
|
||||
<input type="text" name="sk_name" class="jormun-input" placeholder="createdAt" :required="hasSk">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<select name="sk_type" class="jormun-select w-100">
|
||||
<option value="S">String (S)</option>
|
||||
<option value="N">Number (N)</option>
|
||||
<option value="B">Binary (B)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="border-color:#f1f5f9;margin:1rem 0;">
|
||||
|
||||
<div style="background:var(--jormun-teal-xlight);border:1px solid var(--jormun-teal-light);border-radius:6px;padding:0.6rem 0.85rem;margin-bottom:1.25rem;font-size:0.75rem;color:#374151;">
|
||||
<i class="bi bi-lightning-charge" style="color:var(--jormun-teal);"></i>
|
||||
Table will be created with <strong>PAY_PER_REQUEST</strong> billing mode.
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn-jormun">
|
||||
<i class="bi bi-plus-lg"></i> Create Table
|
||||
</button>
|
||||
<a href="{{ path('dashboard') }}" class="btn-jormun-outline">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
145
templates/table/item_form.html.twig
Normal file
145
templates/table/item_form.html.twig
Normal file
@@ -0,0 +1,145 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% import '_sidebar_tables.html.twig' as macros %}
|
||||
|
||||
{% block title %}{{ tableName }} — {{ mode == 'new' ? 'Insert Item' : 'Edit Item' }} · Jormun Admin{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="{{ path('table_browse', {name: tableName}) }}">{{ tableName }}</a>
|
||||
<span class="sep">/</span>
|
||||
<span class="current">{{ mode == 'new' ? 'Insert Item' : 'Edit Item' }}</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar_tables %}
|
||||
{{ macros.sidebar_table_links(tables, tableName) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="op-tabs">
|
||||
<a href="{{ path('table_browse', {name: tableName}) }}" class="op-tab">
|
||||
<i class="bi bi-grid-3x3-gap"></i> Browse
|
||||
</a>
|
||||
<a href="{{ path('table_structure', {name: tableName}) }}" class="op-tab">
|
||||
<i class="bi bi-diagram-3"></i> Structure
|
||||
</a>
|
||||
<a href="{{ path('table_query', {name: tableName}) }}" class="op-tab">
|
||||
<i class="bi bi-search"></i> Query
|
||||
</a>
|
||||
<a href="{{ path('table_item_new', {name: tableName}) }}" class="op-tab {% if mode == 'new' %}active{% endif %}">
|
||||
<i class="bi bi-plus-circle"></i> Insert
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-8 col-xl-7">
|
||||
<div class="content-card">
|
||||
<div class="content-card-header">
|
||||
<h6>
|
||||
<i class="bi bi-{% if mode == 'new' %}plus-circle{% else %}pencil{% endif %} me-1" style="color:var(--jormun-teal);"></i>
|
||||
{{ mode == 'new' ? 'Insert New Item' : 'Edit Item' }}
|
||||
</h6>
|
||||
<span style="font-size:0.72rem;color:#64748b;">{{ tableName }}</span>
|
||||
</div>
|
||||
|
||||
<div style="padding:1.25rem;" x-data="itemForm()">
|
||||
|
||||
{% if error %}
|
||||
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.65rem 0.85rem;margin-bottom:1rem;font-size:0.78rem;">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Key schema hint #}
|
||||
<div style="background:var(--jormun-teal-xlight);border:1px solid var(--jormun-teal-light);border-radius:6px;padding:0.6rem 0.85rem;margin-bottom:1rem;font-size:0.75rem;color:#374151;">
|
||||
<i class="bi bi-info-circle" style="color:var(--jormun-teal);"></i>
|
||||
Required keys:
|
||||
<strong>{{ keySchema.HASH.name ?? '?' }}</strong> ({{ keySchema.HASH.type ?? 'S' }})
|
||||
{% if keySchema.RANGE is defined %}
|
||||
+ <strong>{{ keySchema.RANGE.name }}</strong> ({{ keySchema.RANGE.type }})
|
||||
{% endif %}
|
||||
· Item is a JSON object. Additional attributes are free-form.
|
||||
</div>
|
||||
|
||||
<form method="POST" @submit.prevent="submitForm">
|
||||
{# JSON editor #}
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||
<label style="font-size:0.72rem;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;color:#374151;">
|
||||
Item JSON
|
||||
</label>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" @click="formatJson()" class="btn-jormun-outline" style="font-size:0.7rem;padding:0.2rem 0.5rem;">
|
||||
<i class="bi bi-braces"></i> Format
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
name="item_json"
|
||||
class="json-editor"
|
||||
x-model="jsonText"
|
||||
@input="validateJson()"
|
||||
spellcheck="false"
|
||||
placeholder='{
|
||||
"{{ keySchema.HASH.name ?? 'id' }}": "my-value"{% if keySchema.RANGE is defined %},
|
||||
"{{ keySchema.RANGE.name }}": "sort-value"{% endif %}
|
||||
}'
|
||||
></textarea>
|
||||
<div x-show="jsonError" x-text="jsonError" style="color:#ef4444;font-size:0.72rem;margin-top:0.3rem;"></div>
|
||||
<div x-show="!jsonError && jsonText.length > 2" style="color:var(--jormun-teal);font-size:0.72rem;margin-top:0.3rem;">
|
||||
<i class="bi bi-check-circle"></i> Valid JSON
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn-jormun" :disabled="!!jsonError">
|
||||
<i class="bi bi-{% if mode == 'new' %}plus-lg{% else %}check-lg{% endif %}"></i>
|
||||
{{ mode == 'new' ? 'Insert Item' : 'Save Changes' }}
|
||||
</button>
|
||||
<a href="{{ path('table_browse', {name: tableName}) }}" class="btn-jormun-outline">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('itemForm', () => ({
|
||||
jsonText: JSON.stringify({{ item ? item|json_encode|raw : '{}' }}, null, 2),
|
||||
jsonError: '',
|
||||
validateJson() {
|
||||
try {
|
||||
if (this.jsonText.trim()) JSON.parse(this.jsonText);
|
||||
this.jsonError = '';
|
||||
} catch(e) {
|
||||
this.jsonError = 'JSON error: ' + e.message;
|
||||
}
|
||||
},
|
||||
formatJson() {
|
||||
try {
|
||||
const parsed = JSON.parse(this.jsonText);
|
||||
this.jsonText = JSON.stringify(parsed, null, 2);
|
||||
this.jsonError = '';
|
||||
} catch(e) {
|
||||
this.jsonError = 'Cannot format: ' + e.message;
|
||||
}
|
||||
},
|
||||
submitForm() {
|
||||
try {
|
||||
JSON.parse(this.jsonText);
|
||||
this.$el.submit();
|
||||
} catch(e) {
|
||||
this.jsonError = 'Fix JSON errors before submitting.';
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
208
templates/table/query.html.twig
Normal file
208
templates/table/query.html.twig
Normal file
@@ -0,0 +1,208 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% import '_sidebar_tables.html.twig' as macros %}
|
||||
|
||||
{% block title %}{{ tableName }} — Query · Jormun Admin{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="{{ path('table_browse', {name: tableName}) }}">{{ tableName }}</a>
|
||||
<span class="sep">/</span>
|
||||
<span class="current">Query</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar_tables %}
|
||||
{{ macros.sidebar_table_links(tables, tableName) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="op-tabs">
|
||||
<a href="{{ path('table_browse', {name: tableName}) }}" class="op-tab">
|
||||
<i class="bi bi-grid-3x3-gap"></i> Browse
|
||||
</a>
|
||||
<a href="{{ path('table_structure', {name: tableName}) }}" class="op-tab">
|
||||
<i class="bi bi-diagram-3"></i> Structure
|
||||
</a>
|
||||
<a href="{{ path('table_query', {name: tableName}) }}" class="op-tab active">
|
||||
<i class="bi bi-search"></i> Query
|
||||
</a>
|
||||
<a href="{{ path('table_item_new', {name: tableName}) }}" class="op-tab">
|
||||
<i class="bi bi-plus-circle"></i> Insert
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div x-data="queryBuilder()" class="row g-3">
|
||||
|
||||
{# Builder form #}
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="content-card">
|
||||
<div class="content-card-header">
|
||||
<h6><i class="bi bi-funnel me-1" style="color:var(--jormun-teal);"></i> Query Builder</h6>
|
||||
</div>
|
||||
<div style="padding:1rem;">
|
||||
<form method="POST" action="{{ path('table_query', {name: tableName}) }}">
|
||||
|
||||
{# Mode toggle #}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="font-size:0.72rem;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;color:#374151;">Mode</label>
|
||||
<div class="d-flex gap-2">
|
||||
<label style="display:flex;align-items:center;gap:0.3rem;font-size:0.78rem;cursor:pointer;">
|
||||
<input type="radio" name="mode" value="query" x-model="queryMode" {% if mode != 'scan' %}checked{% endif %}> Query
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.3rem;font-size:0.78rem;cursor:pointer;">
|
||||
<input type="radio" name="mode" value="scan" x-model="queryMode" {% if mode == 'scan' %}checked{% endif %}> Scan (full table)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Conditions (only for Query mode) #}
|
||||
<div x-show="queryMode === 'query'">
|
||||
<div class="mb-2" style="font-size:0.72rem;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;color:#374151;">
|
||||
Key Conditions
|
||||
</div>
|
||||
|
||||
<div class="mb-2" style="font-size:0.72rem;color:#64748b;">
|
||||
PK: <span class="type-badge">{{ keySchema.HASH.name ?? '?' }}</span>
|
||||
{% if keySchema.RANGE is defined %}
|
||||
SK: <span class="type-badge">{{ keySchema.RANGE.name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<template x-for="(cond, i) in conditions" :key="i">
|
||||
<div class="d-flex gap-1 mb-2 align-items-start">
|
||||
<input
|
||||
type="text"
|
||||
:name="'cond_key[]'"
|
||||
x-model="cond.key"
|
||||
class="jormun-input"
|
||||
placeholder="attribute"
|
||||
style="width:35%;"
|
||||
>
|
||||
<select :name="'cond_op[]'" x-model="cond.op" class="jormun-select" style="width:28%;">
|
||||
<option value="=">=</option>
|
||||
<option value="<"><</option>
|
||||
<option value="<="><=</option>
|
||||
<option value=">">></option>
|
||||
<option value=">=">>=</option>
|
||||
<option value="begins_with">begins</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
:name="'cond_val[]'"
|
||||
x-model="cond.val"
|
||||
class="jormun-input"
|
||||
placeholder="value"
|
||||
style="flex:1;"
|
||||
>
|
||||
<button type="button" @click="removeCond(i)" class="btn-danger-sm" style="flex-shrink:0;padding:0.35rem 0.5rem;">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button type="button" @click="addCond()" class="btn-jormun-outline w-100 mb-3" style="font-size:0.72rem;padding:0.3rem;">
|
||||
<i class="bi bi-plus"></i> Add Condition
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-jormun w-100">
|
||||
<i :class="queryMode === 'scan' ? 'bi bi-lightning' : 'bi bi-search'"></i>
|
||||
<span x-text="queryMode === 'scan' ? 'Run Scan' : 'Run Query'"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Results #}
|
||||
<div class="col-12 col-lg-8">
|
||||
{% if error %}
|
||||
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.8rem;">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if results is not null %}
|
||||
<div class="content-card">
|
||||
<div class="content-card-header">
|
||||
<h6><i class="bi bi-table me-1" style="color:var(--jormun-teal);"></i> Results</h6>
|
||||
<span class="stat-pill">{{ results|length }} item{{ results|length != 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
{% if results is empty %}
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-search"></i>
|
||||
<div>No items matched your query.</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Collect columns #}
|
||||
{% set cols = {} %}
|
||||
{% for item in results %}{% for k in item|keys %}{% set cols = cols|merge({(k): true}) %}{% endfor %}{% endfor %}
|
||||
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for col in cols|keys %}
|
||||
<th>{{ col }}
|
||||
{% if keySchema.HASH is defined and keySchema.HASH.name == col %}
|
||||
<span class="type-badge">PK</span>
|
||||
{% endif %}
|
||||
{% if keySchema.RANGE is defined and keySchema.RANGE.name == col %}
|
||||
<span class="type-badge">SK</span>
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in results %}
|
||||
<tr>
|
||||
{% for col in cols|keys %}
|
||||
<td class="cell-value {% if keySchema.HASH is defined and keySchema.HASH.name == col %}pk-cell{% endif %}">
|
||||
{% if item[col] is defined %}
|
||||
{% set val = item[col] %}
|
||||
{% if val is iterable %}
|
||||
<span style="color:#6366f1;">{{ val|json_encode|slice(0, 50) }}{% if val|json_encode|length > 50 %}…{% endif %}</span>
|
||||
{% else %}
|
||||
{{ val|e|slice(0, 60) }}{% if (val|e|length) > 60 %}…{% endif %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="null-val">NULL</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="content-card">
|
||||
<div class="empty-state" style="padding:4rem 1rem;">
|
||||
<i class="bi bi-search" style="color:#cbd5e1;"></i>
|
||||
<div style="font-family:'Syne',sans-serif;font-weight:700;font-size:0.9rem;color:#374151;margin-bottom:0.3rem;">Build a query</div>
|
||||
<div style="font-size:0.75rem;color:#94a3b8;">
|
||||
Set conditions on the left and click Run Query, or use Scan for all items.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function queryBuilder() {
|
||||
return {
|
||||
queryMode: '{{ mode }}',
|
||||
conditions: [{ key: '{{ keySchema.HASH.name ?? '' }}', op: '=', val: '' }],
|
||||
addCond() { this.conditions.push({ key: '', op: '=', val: '' }); },
|
||||
removeCond(i) { if (this.conditions.length > 1) this.conditions.splice(i, 1); }
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
191
templates/table/structure.html.twig
Normal file
191
templates/table/structure.html.twig
Normal file
@@ -0,0 +1,191 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% import '_sidebar_tables.html.twig' as macros %}
|
||||
|
||||
{% block title %}{{ tableName }} — Structure · Jormun Admin{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="{{ path('table_browse', {name: tableName}) }}">{{ tableName }}</a>
|
||||
<span class="sep">/</span>
|
||||
<span class="current">Structure</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar_tables %}
|
||||
{{ macros.sidebar_table_links(tables, tableName) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="op-tabs">
|
||||
<a href="{{ path('table_browse', {name: tableName}) }}" class="op-tab">
|
||||
<i class="bi bi-grid-3x3-gap"></i> Browse
|
||||
</a>
|
||||
<a href="{{ path('table_structure', {name: tableName}) }}" class="op-tab active">
|
||||
<i class="bi bi-diagram-3"></i> Structure
|
||||
</a>
|
||||
<a href="{{ path('table_query', {name: tableName}) }}" class="op-tab">
|
||||
<i class="bi bi-search"></i> Query
|
||||
</a>
|
||||
<a href="{{ path('table_item_new', {name: tableName}) }}" class="op-tab">
|
||||
<i class="bi bi-plus-circle"></i> Insert
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{% if error %}
|
||||
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.8rem;">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if desc %}
|
||||
|
||||
<div class="row g-3">
|
||||
{# Table info #}
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="content-card h-100">
|
||||
<div class="content-card-header">
|
||||
<h6><i class="bi bi-info-circle me-1" style="color:var(--jormun-teal);"></i> Table Info</h6>
|
||||
</div>
|
||||
<div style="padding:0.75rem 1rem;">
|
||||
<table class="data-table">
|
||||
<tr><td style="color:#64748b;width:40%;">Name</td><td><strong>{{ desc.TableName }}</strong></td></tr>
|
||||
<tr><td style="color:#64748b;">Status</td><td><span class="status-badge {{ desc.TableStatus|lower }}">{{ desc.TableStatus }}</span></td></tr>
|
||||
<tr><td style="color:#64748b;">Item Count</td><td>{{ (desc.ItemCount ?? 0)|number_format }}</td></tr>
|
||||
<tr>
|
||||
<td style="color:#64748b;">Size</td>
|
||||
<td>
|
||||
{% set bytes = desc.TableSizeBytes ?? 0 %}
|
||||
{% if bytes >= 1048576 %}{{ (bytes / 1048576)|number_format(2) }} MB
|
||||
{% elseif bytes >= 1024 %}{{ (bytes / 1024)|number_format(2) }} KB
|
||||
{% else %}{{ bytes }} B{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td style="color:#64748b;">Billing Mode</td><td>{{ desc.BillingModeSummary.BillingMode ?? 'PROVISIONED' }}</td></tr>
|
||||
{% if desc.CreationDateTime is defined %}
|
||||
<tr><td style="color:#64748b;">Created</td><td>{{ desc.CreationDateTime|date('Y-m-d H:i:s') }}</td></tr>
|
||||
{% endif %}
|
||||
<tr><td style="color:#64748b;">Table ARN</td><td style="font-size:0.68rem;word-break:break-all;">{{ desc.TableArn ?? '—' }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Key Schema #}
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="content-card h-100">
|
||||
<div class="content-card-header">
|
||||
<h6><i class="bi bi-key me-1" style="color:var(--jormun-teal);"></i> Key Schema</h6>
|
||||
</div>
|
||||
<div style="padding:0.75rem 1rem;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Attribute</th>
|
||||
<th>Key Type</th>
|
||||
<th>Data Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key in desc.KeySchema %}
|
||||
{% set attrType = 'S' %}
|
||||
{% for attr in desc.AttributeDefinitions %}
|
||||
{% if attr.AttributeName == key.AttributeName %}
|
||||
{% set attrType = attr.AttributeType %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td class="pk-cell">{{ key.AttributeName }}</td>
|
||||
<td>
|
||||
<span class="status-badge {{ key.KeyType == 'HASH' ? 'active' : 'creating' }}">
|
||||
{{ key.KeyType == 'HASH' ? 'Partition Key' : 'Sort Key' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="type-badge">
|
||||
{% if attrType == 'S' %}String (S)
|
||||
{% elseif attrType == 'N' %}Number (N)
|
||||
{% elseif attrType == 'B' %}Binary (B)
|
||||
{% else %}{{ attrType }}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Secondary Indexes #}
|
||||
{% if desc.GlobalSecondaryIndexes is defined and desc.GlobalSecondaryIndexes|length > 0 %}
|
||||
<div class="col-12">
|
||||
<div class="content-card">
|
||||
<div class="content-card-header">
|
||||
<h6><i class="bi bi-diagram-2 me-1" style="color:var(--jormun-teal);"></i> Global Secondary Indexes</h6>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Index Name</th>
|
||||
<th>Status</th>
|
||||
<th>Partition Key</th>
|
||||
<th>Sort Key</th>
|
||||
<th>Projection</th>
|
||||
<th>Item Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for gsi in desc.GlobalSecondaryIndexes %}
|
||||
{% set gsiPk = null %}{% set gsiSk = null %}
|
||||
{% for k in gsi.KeySchema %}
|
||||
{% if k.KeyType == 'HASH' %}{% set gsiPk = k.AttributeName %}{% endif %}
|
||||
{% if k.KeyType == 'RANGE' %}{% set gsiSk = k.AttributeName %}{% endif %}
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td class="pk-cell">{{ gsi.IndexName }}</td>
|
||||
<td><span class="status-badge {{ (gsi.IndexStatus ?? 'ACTIVE')|lower }}">{{ gsi.IndexStatus ?? 'ACTIVE' }}</span></td>
|
||||
<td><span class="type-badge">{{ gsiPk }}</span></td>
|
||||
<td>{% if gsiSk %}<span class="type-badge">{{ gsiSk }}</span>{% else %}<span class="null-val">none</span>{% endif %}</td>
|
||||
<td>{{ gsi.Projection.ProjectionType ?? '—' }}</td>
|
||||
<td>{{ (gsi.ItemCount ?? 0)|number_format }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if desc.LocalSecondaryIndexes is defined and desc.LocalSecondaryIndexes|length > 0 %}
|
||||
<div class="col-12">
|
||||
<div class="content-card">
|
||||
<div class="content-card-header">
|
||||
<h6><i class="bi bi-diagram-2 me-1" style="color:var(--jormun-teal);"></i> Local Secondary Indexes</h6>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Index Name</th><th>Sort Key</th><th>Projection</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lsi in desc.LocalSecondaryIndexes %}
|
||||
{% set lsiSk = null %}
|
||||
{% for k in lsi.KeySchema %}{% if k.KeyType == 'RANGE' %}{% set lsiSk = k.AttributeName %}{% endif %}{% endfor %}
|
||||
<tr>
|
||||
<td class="pk-cell">{{ lsi.IndexName }}</td>
|
||||
<td><span class="type-badge">{{ lsiSk ?? '—' }}</span></td>
|
||||
<td>{{ lsi.Projection.ProjectionType ?? '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user