From 9ddfcb88c1f7988020c1347c2f64d0b03df36d53 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Fri, 28 Aug 2026 00:34:10 +1000 Subject: [PATCH 1/7] feat(admin): signage AI providers page A Data Stores shaped page for the vendor credentials behind signage image generation, plus a usage table so a domain's spend is visible. Credentials are only ever sent, never returned, so the boxes start empty on an edit and leaving them empty keeps what is stored. The form swaps its middle section per vendor rather than showing every field at once, and a test button asks the vendor for one small image so a wrong key is caught here rather than by a user mid poster. Co-Authored-By: Claude Opus 5 (1M context) --- public/assets/locale/en-AU.json | 490 ++++++++++-------- src/app/admin/admin.component.ts | 5 + src/app/admin/admin.routes.ts | 7 + .../signage-ai-provider-modal.component.ts | 287 ++++++++++ .../admin/signage-ai/signage-ai.component.ts | 310 +++++++++++ src/app/admin/signage-ai/signage-ai.fn.ts | 85 +++ 6 files changed, 959 insertions(+), 225 deletions(-) create mode 100644 src/app/admin/signage-ai/signage-ai-provider-modal.component.ts create mode 100644 src/app/admin/signage-ai/signage-ai.component.ts create mode 100644 src/app/admin/signage-ai/signage-ai.fn.ts diff --git a/public/assets/locale/en-AU.json b/public/assets/locale/en-AU.json index 4bec56b55..df1005b39 100644 --- a/public/assets/locale/en-AU.json +++ b/public/assets/locale/en-AU.json @@ -915,276 +915,316 @@ "DELETE_CASCADE_DESC": "Removes this domain's OAuth applications and staff API tenant, plus its org zone tree when no other domain uses it." }, "ADMIN": { - "TITLE": "Admin", - "TAB_ABOUT": "About", - "TAB_DATABASE": "Database", - "TAB_CLUSTERS": "Clusters", - "TAB_EDGES": "Edges", - "TAB_INTERFACES": "Interfaces", - "TAB_MQTT_BROKERS": "MQTT Brokers", - "TAB_TENANT_CONFIG": "Staff Tenants", - "TAB_RESOURCE_IMPORTS": "Resource Imports", - "TAB_EXTENSIONS": "Extensions", - "TAB_API_KEYS": "Application Keys", - "TAB_CUSTOM_SCHEMAS": "Custom Schemas", - "TAB_UPLOAD_STORAGE": "Data Stores", - "TAB_UPLOADS_LIBRARY": "Uploads Library", - "TAB_BUILD_JOBS": "Build Jobs", + "AI_PROVIDERS_EMPTY": "No AI provider is configured. Signage image generation stays hidden until one is added.", + "AI_PROVIDERS_HEADER": "Signage AI providers", + "AI_PROVIDERS_LOADING": "Loading providers...", + "AI_PROVIDER_ADD": "Add provider", + "AI_PROVIDER_API_KEY": "API key", + "AI_PROVIDER_API_VERSION": "API version", + "AI_PROVIDER_CLIENT_EMAIL": "Service account email", + "AI_PROVIDER_CREDENTIALS_REQUIRED": "Credentials are needed to add a provider", + "AI_PROVIDER_DEPLOYMENT": "Deployment", + "AI_PROVIDER_DOMAIN_QUOTA": "Images per domain per month", + "AI_PROVIDER_EDIT": "Edit AI provider", + "AI_PROVIDER_ENABLED": "Enabled", + "AI_PROVIDER_ENDPOINT": "Endpoint (optional)", + "AI_PROVIDER_IS_DEFAULT": "Default", + "AI_PROVIDER_MODEL": "Default model", + "AI_PROVIDER_NAME": "Name", + "AI_PROVIDER_NAME_REQUIRED": "A name is required", + "AI_PROVIDER_NEW": "New AI provider", + "AI_PROVIDER_PRIVATE_KEY": "Service account private key", + "AI_PROVIDER_PROJECT": "Google project id", + "AI_PROVIDER_REMOVE": "Remove provider", + "AI_PROVIDER_REMOVE_MSG": "Remove {{ name }}? Artwork already generated is kept, but no new images can be made until another provider is configured.", + "AI_PROVIDER_REMOVE_TITLE": "Remove AI provider", + "AI_PROVIDER_REMOVING": "Removing provider...", + "AI_PROVIDER_SAVE_ERROR": "The provider could not be saved", + "AI_PROVIDER_SAVE_SUCCESS": "Provider saved", + "AI_PROVIDER_SAVING": "Saving provider...", + "AI_PROVIDER_TEST": "Test credentials", + "AI_PROVIDER_TESTING": "Asking the vendor for a test image...", + "AI_PROVIDER_TEST_FAILED": "The vendor did not answer", + "AI_PROVIDER_TEST_OK": "Working, answered in {{ ms }} ms", + "AI_PROVIDER_USER_QUOTA": "Images per person per day", + "AI_PROVIDER_VENDOR": "Vendor", + "AI_USAGE_ASKED": "Images asked for", + "AI_USAGE_EMPTY": "Nothing generated yet.", + "AI_USAGE_HEADER": "Usage, last 30 days", + "AI_USAGE_HINT": "What this domain has asked for, per vendor and model.", + "AI_USAGE_JOBS": "Requests", + "AI_USAGE_MADE": "Images made", + "ALL_DOMAINS": "All Domains", "APPLICATION_DETAILS": "Application Details", - "VIEW_CHANGELOG": "View Changelog", - "BUILD": "Build", - "BUILT_AT": "Build", + "APP_KEYS_ADD": "Add App Key", + "APP_KEYS_COPIED": "Copied Application Key to clipboard", + "APP_KEYS_EDIT": "Edit Application Key", + "APP_KEYS_FIELD_EXPIRES_AT": "Expires At", + "APP_KEYS_FIELD_PERMISSIONS": "Access", + "APP_KEYS_FIELD_SCOPES": "Scopes", + "APP_KEYS_FIELD_TTL": "Time To Live", + "APP_KEYS_FIELD_TTL_HINT": "Seconds until the key expires", + "APP_KEYS_HEADER": "PlaceOS Application Keys", + "APP_KEYS_LAST_DETAILS": "Last API Key", + "APP_KEYS_LIST_EMPTY": "No application keys configured for this domain", + "APP_KEYS_NAME_REQUIRED": "A name is required", + "APP_KEYS_NEW": "New Application Key", + "APP_KEYS_PERMISSIONS_EMPTY": "No permissions", + "APP_KEYS_QUICK_CREATE": "Quick Create", + "APP_KEYS_REMOVE": "Remove Application Key", + "APP_KEYS_SCOPES_REQUIRE": "At least one scope is required", + "APP_KEYS_SECONDS": "seconds", + "APP_KEYS_TTL_6_HOURS": "6 Hours", + "APP_KEYS_TTL_90_DAYS": "90 Days", + "APP_KEYS_TTL_DAY": "1 Day", + "APP_KEYS_TTL_HOUR": "1 Hour", + "APP_KEYS_TTL_MONTH": "30 Days", + "APP_KEYS_TTL_NONE": "No expiry", + "APP_KEYS_TTL_WEEK": "1 Week", + "APP_KEYS_TTL_YEAR": "1 Year", "BACKEND_SERVICES": "Backend Services", - "PLATFORM": "Platform", - "COPIED": "Copied {{ name }} to clipboard", - "ALL_DOMAINS": "All Domains", - "SELECT_DOMAIN": "Select domain...", "BACKEND_SERVICES_EMPTY": "No API service details available.", "BACKEND_SERVICES_ERROR": "Failed to load API details. Error: {{ error }}", + "BROKERS_ADD": "Add MQTT Broker", + "BROKERS_AUTH_TYPE_CERT": " Certificate", + "BROKERS_AUTH_TYPE_NONE": "No Auth", + "BROKERS_AUTH_TYPE_PASS": "User Password", + "BROKERS_CERT": "Certificate", + "BROKERS_CERT_REQUIRED": "A valid certificate is required ", + "BROKERS_EDIT": "Edit MQTT Broker", + "BROKERS_FIELD_AUTH_TYPE": "Auth Type", + "BROKERS_FIELD_FILTERS": "Filters", + "BROKERS_FIELD_HOST": "Host", + "BROKERS_FIELD_PORT": "Port", + "BROKERS_FIELD_TLS": "TLS", + "BROKERS_FILTERS_EMPTY": "No filters", + "BROKERS_HEADER": "MQTT Brokers", + "BROKERS_HOST_REQUIRED": "A broker name is required", + "BROKERS_NAME_REQUIRED": "A broker name is required", + "BROKERS_NEW": "New MQTT Broker", + "BROKERS_PASSWORD": "Password", + "BROKERS_PASSWORD_REQUIRED": "A valid password is required ", + "BROKERS_PORT_REQUIRED": "A valid port number between 1 - 65535 is required", + "BROKERS_REMOVE": "Remove MQTT Broker", + "BROKERS_USERNAME": "Username", + "BROKERS_USERNAME_REQUIRED": "A valid username is required", + "BROKER_LIST_EMPTY": "No MQTT Brokers", + "BUILD": "Build", + "BUILD_LIST_EMPTY": "No build jobs available to monitor", + "BUILD_LIST_HEADER": "Build Service Jobs", + "BUILD_LIST_REMOVE": "Cancel Build Job", + "BUILD_LIST_REMOVE_ERROR": "Failed to cancel build job. Error: {{ error }}", + "BUILD_LIST_REMOVE_LOADING": "Cancelling build job...", + "BUILD_LIST_REMOVE_MSG": "Are you sure you wish to cancel the build job for {{ driver }} from {{ repo }}?", + "BUILD_LIST_REMOVE_SUCCESS": "Successfully cancelled build job.", + "BUILT_AT": "Build", + "CLUSTER": "Cluster", "CLUSTERS": "PlaceOS Clusters", - "CLUSTERS_VIEW_PROCESSES": "View Processes", - "CLUSTERS_LIST_EMPTY": "No cluster details available", - "CLUSTERS_CPU_USAGE": "CPU Usage", - "CLUSTERS_MEMORY_USAGE": "Memory Usage", "CLUSTERS_CPU_CORES": "{{ count }} cores", - "CLUSTER": "Cluster", - "CLUSTERS_SEARCH_PROCESSES": "Search processes...", + "CLUSTERS_CPU_USAGE": "CPU Usage", "CLUSTERS_FIELD_CPU_USAGE": "CPU %", - "CLUSTERS_FIELD_MEMORY_USAGE": "Memory", "CLUSTERS_FIELD_INSTANCES": "Instances", + "CLUSTERS_FIELD_MEMORY_USAGE": "Memory", + "CLUSTERS_LIST_EMPTY": "No cluster details available", + "CLUSTERS_MEMORY_USAGE": "Memory Usage", + "CLUSTERS_SEARCH_PROCESSES": "Search processes...", + "CLUSTERS_VIEW_PROCESSES": "View Processes", "CLUSTER_PROCESSES_EMPTY": "No tasks running on this cluster", "CLUSTER_PROCESS_KILL": "Kill process", - "CLUSTER_PROCESS_KILL_MSG": "

Are you sure you want kill the process for \"{{ id }}\"?

The process will be terminated immediately.The process may be restarted after a short while.

", - "CLUSTER_PROCESS_KILL_LOADING": "Terminating process. Please wait...", "CLUSTER_PROCESS_KILL_ERROR": "Failed to kill process. Error: {{ error }}", - "EDGE_HEADER": "PlaceOS Edge Nodes", + "CLUSTER_PROCESS_KILL_LOADING": "Terminating process. Please wait...", + "CLUSTER_PROCESS_KILL_MSG": "

Are you sure you want kill the process for \"{{ id }}\"?

The process will be terminated immediately.The process may be restarted after a short while.

", + "COPIED": "Copied {{ name }} to clipboard", "EDGE_ADD": "Add Edge node", - "EDGE_NEW": "New Edge node", + "EDGE_DESCRIPTION_PLACEHOLDER": "Description of the Edge node", "EDGE_EDIT": "Edit Edge node", - "EDGE_SAVING": "Saving edge node...", - "EDGE_REMOVE": "Remove Edge node", + "EDGE_EDIT_SUCCESS": "Successfully added new edge. Please make sure to save the API key as you will not be able to view it again in the future.", "EDGE_ERROR": "Failed to update edge node.", + "EDGE_HEADER": "PlaceOS Edge Nodes", + "EDGE_LIST_EMPTY": "No edges available on this cluster", "EDGE_NAME_PLACEHOLDER": "Name of the Edge node", - "EDGE_DESCRIPTION_PLACEHOLDER": "Description of the Edge node", "EDGE_NAME_REQUIRED": "An Edge name is required", + "EDGE_NEW": "New Edge node", "EDGE_NEW_SUCCESS": "Successfully updated edge.", - "EDGE_EDIT_SUCCESS": "Successfully added new edge. Please make sure to save the API key as you will not be able to view it again in the future.", - "EDGE_LIST_EMPTY": "No edges available on this cluster", - "INTERFACES_HEADER": "PlaceOS Interfaces", - "INTERFACES_COMMIT_EMPTY": "No commit hash", - "BROKERS_HEADER": "MQTT Brokers", - "BROKERS_ADD": "Add MQTT Broker", - "BROKERS_NEW": "New MQTT Broker", - "BROKERS_EDIT": "Edit MQTT Broker", - "BROKERS_REMOVE": "Remove MQTT Broker", - "BROKER_LIST_EMPTY": "No MQTT Brokers", - "BROKERS_FIELD_HOST": "Host", - "BROKERS_FIELD_PORT": "Port", - "BROKERS_FIELD_TLS": "TLS", - "BROKERS_FIELD_FILTERS": "Filters", - "BROKERS_FIELD_AUTH_TYPE": "Auth Type", - "BROKERS_AUTH_TYPE_CERT": " Certificate", - "BROKERS_AUTH_TYPE_PASS": "User Password", - "BROKERS_AUTH_TYPE_NONE": "No Auth", - "BROKERS_FILTERS_EMPTY": "No filters", - "BROKERS_NAME_REQUIRED": "A broker name is required", - "BROKERS_HOST_REQUIRED": "A broker name is required", - "BROKERS_PORT_REQUIRED": "A valid port number between 1 - 65535 is required", - "BROKERS_USERNAME": "Username", - "BROKERS_USERNAME_REQUIRED": "A valid username is required", - "BROKERS_PASSWORD": "Password", - "BROKERS_PASSWORD_REQUIRED": "A valid password is required ", - "BROKERS_CERT": "Certificate", - "BROKERS_CERT_REQUIRED": "A valid certificate is required ", - "TENANTS_HEADER": "Staff API Tenants", - "TENANTS_ADD": "Add Tenant", - "TENANTS_NEW": "New Tenant", - "TENANTS_EDIT": "Edit Tenant", - "TENANTS_REMOVE": "Remove Tenant", - "TENANTS_PLATFORM": "Platform", - "TENANTS_SECRET_EXPIRY": "Expires", - "TENANTS_EMPTY": "No tenants for the selected domain", - "TENANTS_EDIT_BOOKING_LIMITS": "Edit Booking Limits", - "TENANTS_FIELD_NAME": "Tenant name", - "TENANTS_NAME_ERROR": "A tenant name is required", - "TENANTS_EMAIL_DOMAIN": "Email Domain", - "TENANTS_EMAIL_PLACEHOLDER": "Domain of user's email for this tenant", - "TENANTS_EMAIL_ERROR": "A domain is required", - "TENANTS_EARLY_CHECKIN": "Early Check-in before Meeting", - "TENANTS_SERVICE_ACCOUNT": "Service Account", - "TENANTS_SERVICE_ACCOUNT_ERROR": "Service account should be a valid email address", - "TENANTS_DELEGATED": "Delegated", - "TENANTS_ITEM_REQUIRED": "A {{ name }} is required", - "TENANTS_CONFIG_OUTLOOK": "Configure Outlook Plugin", - "TENANTS_APP_ID": "Outlook App ID", - "TENANTS_APP_ID_REQUIRED": "An application ID is required", - "TENANTS_APP_DOMAIN": "Outlook App Domain", - "TENANTS_APP_DOMAIN_REQUIRED": "An application domain is required", - "TENANTS_APP_RESOURCE": "Outlook App Resource", - "TENANTS_APP_RESOURCE_REQUIRED": "An application resource is required", - "TENANTS_SOURCE_LOCATION": "Outlook Source Location", - "TENANTS_SOURCE_LOCATION_REQUIRED": "A source location is required", - "TENANTS_BASE_PATH": "Outlook Base Path", - "TENANTS_BASE_PATH_REQUIRED": "Base path to the application root folder is required", - "TENANTS_BOOKING_LIMITS": "Booking Limits", - "TENANTS_MINUTES": "{{ minute }} minutes", - "TENANTS_HOURS": "{{ hour }} hours", - "TENANTS_MINUTES_HOURS": "{{ hour }} hours {{ minute }} minutes", - "TENANTS_SAVE_ERROR": "Failed to update tenant.", - "TENANTS_SAVE_SUCCESS": "Successfully updated tenant.", - "RESOURCE_IMPORTS_HEADER": "Resource Imports", - "RESOURCE_IMPORTS_ALL": "Import All", - "RESOURCE_IMPORTS_IMPORTED": "Imported", - "RESOURCE_IMPORTS_IMPORT": "Import resource", - "RESOURCE_IMPORTS_VIEW": "View System", - "RESOURCE_IMPORTS_EMPTY": "No resource available to import on selected domain", - "RESOURCE_IMPORTS_ALL_WARNING": "All resources are already imported.", - "RESOURCE_IMPORTS_ALL_TITLE": "Import missing resources?", - "RESOURCE_IMPORTS_ALL_MSG": "Are you sure you want to import the following {{ count }} resources?", - "RESOURCE_IMPORTS_ALL_LOADING": "Importing missing resources...", - "RESOURCE_IMPORTS_ALL_SUCCESS": "Successfully imported {{ count }} resources.", - "RESOURCE_IMPORTS_SUCCESS": "Successfully imported resource {{ name }}.", - "EXTENSIONS_HEADER": "Extensions", + "EDGE_REMOVE": "Remove Edge node", + "EDGE_SAVING": "Saving edge node...", "EXTENSIONS_ADD": "Add Extension", - "EXTENSIONS_NEW": "New Extension", + "EXTENSIONS_CONDITION_ADD": "Add Condition", + "EXTENSIONS_CONDITION_FIELD": "Condition Field", + "EXTENSIONS_CONDITION_OP": "Operation", + "EXTENSIONS_CONDITION_VALUE": "Condition value", "EXTENSIONS_EDIT": "Edit Extension", - "EXTENSIONS_REMOVE": "Remove Extension", - "EXTENSIONS_SAVING": "Saving extension...", - "EXTENSIONS_NOTICE": "Note: Backoffice requires a full page refresh for extension changes and additions to apply", - "EXTENSIONS_LIST_EMPTY": "No extensions available for the selected domain", "EXTENSIONS_FIELD_CHECKS": "Checks", "EXTENSIONS_FIELD_CONDITIONS": "Conditions", - "EXTENSIONS_FIELD_TYPE": "Type", "EXTENSIONS_FIELD_NAME": "Extension Display Name", - "EXTENSIONS_NAME_REQUIRED": "A valid name is required", "EXTENSIONS_FIELD_TAB": "Tab Name", + "EXTENSIONS_FIELD_TYPE": "Type", "EXTENSIONS_FIELD_URL": "URL", + "EXTENSIONS_HEADER": "Extensions", + "EXTENSIONS_LIST_EMPTY": "No extensions available for the selected domain", + "EXTENSIONS_NAME_REQUIRED": "A valid name is required", + "EXTENSIONS_NEW": "New Extension", + "EXTENSIONS_NOTICE": "Note: Backoffice requires a full page refresh for extension changes and additions to apply", + "EXTENSIONS_REMOVE": "Remove Extension", + "EXTENSIONS_SAVING": "Saving extension...", "EXTENSIONS_URL_REQUIRED": "A valid URL is required", - "EXTENSIONS_CONDITION_ADD": "Add Condition", - "EXTENSIONS_CONDITION_VALUE": "Condition value", - "EXTENSIONS_CONDITION_OP": "Operation", - "EXTENSIONS_CONDITION_FIELD": "Condition Field", - "APP_KEYS_HEADER": "PlaceOS Application Keys", - "APP_KEYS_ADD": "Add App Key", - "APP_KEYS_QUICK_CREATE": "Quick Create", - "APP_KEYS_NEW": "New Application Key", - "APP_KEYS_EDIT": "Edit Application Key", - "APP_KEYS_REMOVE": "Remove Application Key", - "APP_KEYS_LAST_DETAILS": "Last API Key", - "APP_KEYS_FIELD_SCOPES": "Scopes", - "APP_KEYS_FIELD_PERMISSIONS": "Access", - "APP_KEYS_FIELD_EXPIRES_AT": "Expires At", - "APP_KEYS_FIELD_TTL": "Time To Live", - "APP_KEYS_FIELD_TTL_HINT": "Seconds until the key expires", - "APP_KEYS_SECONDS": "seconds", - "APP_KEYS_TTL_NONE": "No expiry", - "APP_KEYS_TTL_HOUR": "1 Hour", - "APP_KEYS_TTL_6_HOURS": "6 Hours", - "APP_KEYS_TTL_DAY": "1 Day", - "APP_KEYS_TTL_WEEK": "1 Week", - "APP_KEYS_TTL_MONTH": "30 Days", - "APP_KEYS_TTL_90_DAYS": "90 Days", - "APP_KEYS_TTL_YEAR": "1 Year", - "APP_KEYS_SCOPES_REQUIRE": "At least one scope is required", - "APP_KEYS_NAME_REQUIRED": "A name is required", - "APP_KEYS_PERMISSIONS_EMPTY": "No permissions", - "APP_KEYS_COPIED": "Copied Application Key to clipboard", - "APP_KEYS_LIST_EMPTY": "No application keys configured for this domain", + "INTERFACES_COMMIT_EMPTY": "No commit hash", + "INTERFACES_HEADER": "PlaceOS Interfaces", "PERMISSIONS": "Permissions", "PERMISSIONS_ADMIN": "Admin", + "PERMISSIONS_NONE": "None", "PERMISSIONS_SUPPORT": "Support", "PERMISSIONS_USER": "User", - "PERMISSIONS_NONE": "None", - "SCHEMA_HEADER": "Custom Schemas", - "SCHEMA_SELECT": "Select Schema...", + "PLATFORM": "Platform", + "RESOURCE_IMPORTS_ALL": "Import All", + "RESOURCE_IMPORTS_ALL_LOADING": "Importing missing resources...", + "RESOURCE_IMPORTS_ALL_MSG": "Are you sure you want to import the following {{ count }} resources?", + "RESOURCE_IMPORTS_ALL_SUCCESS": "Successfully imported {{ count }} resources.", + "RESOURCE_IMPORTS_ALL_TITLE": "Import missing resources?", + "RESOURCE_IMPORTS_ALL_WARNING": "All resources are already imported.", + "RESOURCE_IMPORTS_EMPTY": "No resource available to import on selected domain", + "RESOURCE_IMPORTS_HEADER": "Resource Imports", + "RESOURCE_IMPORTS_IMPORT": "Import resource", + "RESOURCE_IMPORTS_IMPORTED": "Imported", + "RESOURCE_IMPORTS_SUCCESS": "Successfully imported resource {{ name }}.", + "RESOURCE_IMPORTS_VIEW": "View System", "SCHEMA_ADD": "Add Schema", + "SCHEMA_HEADER": "Custom Schemas", "SCHEMA_NAME": "Schema Name", "SCHEMA_NEW": "New Schema", + "SCHEMA_SELECT": "Select Schema...", "SCHEMA_SELECT_MSG": "Select a schema to edit", - "STORAGE_HEADER": "PlaceOS Data Stores", - "STORAGE_ADD": "Add Provider", - "STORAGE_NEW": "New Storage Provider", - "STORAGE_EDIT": "Edit Storage Provider", - "STORAGE_REMOVE": "Remove Storage Provider", - "STORAGE_REMOVE_TITLE": "Remove Storage Provider", - "STORAGE_REMOVE_MSG": "Are you sure you want to remove the storage provider for {{ type }} {{name}}?", - "STORAGE_REMOVE_LOADING": "Removing storage provider...", - "STORAGE_LOADING": "Loading storage providers...", - "STORAGE_FIELD_TYPE": "Type", - "STORAGE_FIELD_REGION": "Region", - "STORAGE_FIELD_DEFAULT": "Default", - "STORAGE_LIST_EMPTY": "No storage providers for {{ item }} domain", - "STORAGE_SAVE_SUCCESS": "Successfully saved storage provider.", - "STORAGE_SAVE_ERROR": "Failed to save storage provider.", - "STORAGE_BUCKET_NAME_LABEL": "Bucket Name", - "STORAGE_BUCKET_NAME_REQUIRED": "Bucket Name is required", - "STORAGE_REGION_LABEL": "Region", - "STORAGE_REGION_REQUIRED": "Region is required", - "STORAGE_ENDPOINT_LABEL": "Endpoint", - "STORAGE_ACCESS_KEY_LABEL": "Access Key", - "STORAGE_ACCESS_KEY_REQUIRED": "Access Key is required", - "STORAGE_SECRET_LABEL": "Access Secret", - "STORAGE_SECRET_REQUIRED": "Access Secret is required", - "STORAGE_ALLOWED_EXTENSIONS_LABEL": "Allowed file extensions", - "STORAGE_ALLOWED_MIME_LABEL": "Allowed file MIME types", - "STORAGE_ALLOW_ALL_EXTENSIONS": "Allow all file extensions", - "STORAGE_ALLOW_ALL_MIME": "Allow all MIME types", - "STORAGE_IS_DEFAULT_LABEL": "Is Default", - "UPLOADS_LIB_HEADER": "Uploads Library", - "UPLOADS_LIB_DOWNLOAD": "Download Upload", - "UPLOADS_LIB_DOWNLOAD_ERROR": "Failed to download upload. Error: {{ error }}", - "UPLOADS_LIB_VIEW": "View Upload", - "UPLOADS_LIB_REMOVE": "Remove Upload", - "UPLOADS_LIB_REMOVE_MSG": "

Are you sure you want remove the upload \"{{ filename }}\"?

The upload will be deleted immediately.

", - "UPLOADS_LIB_REMOVE_LOADING": "Removing upload...", - "UPLOADS_LIB_FIELD_TYPE": "File Type", - "UPLOADS_LIB_FIELD_SIZE": "Size", - "UPLOADS_LIB_LIST_EMPTY": "No uploads for the selected domain", - "UPLOADS_LIB_COPY": "Copy Upload URL", - "TAB_SIGNAGE_PLUGINS": "Signage Plugins", - "SIGNAGE_PLUGINS_HEADER": "Signage Plugins", + "SELECT_DOMAIN": "Select domain...", "SIGNAGE_PLUGINS_ADD": "Add Plugin", + "SIGNAGE_PLUGINS_DEFAULTS_INVALID": "Please fix the invalid defaults fields before saving.", "SIGNAGE_PLUGINS_EDIT": "Edit Signage Plugin", + "SIGNAGE_PLUGINS_FIELD_DEFAULTS": "Defaults", + "SIGNAGE_PLUGINS_FIELD_ENABLED": "Enabled", + "SIGNAGE_PLUGINS_FIELD_PLAYBACK_TYPE": "Playback Type", + "SIGNAGE_PLUGINS_FIELD_TYPE": "Type", + "SIGNAGE_PLUGINS_FIELD_URI": "URI", + "SIGNAGE_PLUGINS_HEADER": "Signage Plugins", + "SIGNAGE_PLUGINS_LIST_EMPTY": "No signage plugins available", + "SIGNAGE_PLUGINS_LOAD_ERROR": "Error loading signage plugins. Error: {{ error }}", + "SIGNAGE_PLUGINS_NAME_REQUIRED": "A plugin name is required", "SIGNAGE_PLUGINS_NEW": "New Signage Plugin", + "SIGNAGE_PLUGINS_PLAYBACK_INTERACTIVE": "Interactive", + "SIGNAGE_PLUGINS_PLAYBACK_PLAYSTHROUGH": "Plays Through", + "SIGNAGE_PLUGINS_PLAYBACK_STATIC": "Static", "SIGNAGE_PLUGINS_REMOVE": "Remove Plugin", - "SIGNAGE_PLUGINS_REMOVE_MSG": "

Are you sure you want to delete this signage plugin?

The plugin will be deleted immediately.

", - "SIGNAGE_PLUGINS_REMOVE_LOADING": "Deleting signage plugin...", "SIGNAGE_PLUGINS_REMOVE_ERROR": "Error deleting signage plugin. Error: {{ error }}", + "SIGNAGE_PLUGINS_REMOVE_LOADING": "Deleting signage plugin...", + "SIGNAGE_PLUGINS_REMOVE_MSG": "

Are you sure you want to delete this signage plugin?

The plugin will be deleted immediately.

", "SIGNAGE_PLUGINS_REMOVE_SUCCESS": "Successfully deleted signage plugin.", - "SIGNAGE_PLUGINS_LOAD_ERROR": "Error loading signage plugins. Error: {{ error }}", - "SIGNAGE_PLUGINS_PLAYBACK_STATIC": "Static", - "SIGNAGE_PLUGINS_PLAYBACK_INTERACTIVE": "Interactive", - "SIGNAGE_PLUGINS_PLAYBACK_PLAYSTHROUGH": "Plays Through", - "SIGNAGE_PLUGINS_LIST_EMPTY": "No signage plugins available", - "SIGNAGE_PLUGINS_FIELD_TYPE": "Type", - "SIGNAGE_PLUGINS_TYPE_PLUGIN": "Plugin", - "SIGNAGE_PLUGINS_TYPE_WIDGET": "Widget", - "SIGNAGE_PLUGINS_FIELD_URI": "URI", - "SIGNAGE_PLUGINS_FIELD_PLAYBACK_TYPE": "Playback Type", - "SIGNAGE_PLUGINS_FIELD_ENABLED": "Enabled", - "SIGNAGE_PLUGINS_FIELD_DEFAULTS": "Defaults", - "SIGNAGE_PLUGINS_NAME_REQUIRED": "A plugin name is required", - "SIGNAGE_PLUGINS_URI_REQUIRED": "A plugin URI is required", - "SIGNAGE_PLUGINS_DEFAULTS_INVALID": "Please fix the invalid defaults fields before saving.", - "SIGNAGE_PLUGINS_SAVING": "Saving signage plugin...", - "SIGNAGE_PLUGINS_SAVE_SUCCESS": "Successfully saved signage plugin.", "SIGNAGE_PLUGINS_SAVE_ERROR": "Error saving signage plugin. Error: {{ error }}", - "SIGNAGE_PLUGINS_SCHEMA_LOADING": "Loading plugin schema...", - "SIGNAGE_PLUGINS_SCHEMA_ERROR": "Failed to load schema from plugin. You can still edit defaults manually.", + "SIGNAGE_PLUGINS_SAVE_SUCCESS": "Successfully saved signage plugin.", + "SIGNAGE_PLUGINS_SAVING": "Saving signage plugin...", "SIGNAGE_PLUGINS_SCHEMA_EMPTY": "Enter a plugin URI to load the configuration schema.", + "SIGNAGE_PLUGINS_SCHEMA_ERROR": "Failed to load schema from plugin. You can still edit defaults manually.", + "SIGNAGE_PLUGINS_SCHEMA_LOADING": "Loading plugin schema...", "SIGNAGE_PLUGINS_TEST": "Test Plugin", - "SIGNAGE_PLUGINS_TEST_HEADING": "Test Signage Plugin", "SIGNAGE_PLUGINS_TEST_CONFIG": "Configuration", + "SIGNAGE_PLUGINS_TEST_HEADING": "Test Signage Plugin", "SIGNAGE_PLUGINS_TEST_NO_SCHEMA": "This plugin does not provide a configuration schema.", - "SIGNAGE_PLUGINS_TEST_SEND": "Send Config", "SIGNAGE_PLUGINS_TEST_PLAY": "Play", - "BUILD_LIST_HEADER": "Build Service Jobs", - "BUILD_LIST_EMPTY": "No build jobs available to monitor", - "BUILD_LIST_REMOVE": "Cancel Build Job", - "BUILD_LIST_REMOVE_MSG": "Are you sure you wish to cancel the build job for {{ driver }} from {{ repo }}?", - "BUILD_LIST_REMOVE_LOADING": "Cancelling build job...", - "BUILD_LIST_REMOVE_ERROR": "Failed to cancel build job. Error: {{ error }}", - "BUILD_LIST_REMOVE_SUCCESS": "Successfully cancelled build job." + "SIGNAGE_PLUGINS_TEST_SEND": "Send Config", + "SIGNAGE_PLUGINS_TYPE_PLUGIN": "Plugin", + "SIGNAGE_PLUGINS_TYPE_WIDGET": "Widget", + "SIGNAGE_PLUGINS_URI_REQUIRED": "A plugin URI is required", + "STORAGE_ACCESS_KEY_LABEL": "Access Key", + "STORAGE_ACCESS_KEY_REQUIRED": "Access Key is required", + "STORAGE_ADD": "Add Provider", + "STORAGE_ALLOWED_EXTENSIONS_LABEL": "Allowed file extensions", + "STORAGE_ALLOWED_MIME_LABEL": "Allowed file MIME types", + "STORAGE_ALLOW_ALL_EXTENSIONS": "Allow all file extensions", + "STORAGE_ALLOW_ALL_MIME": "Allow all MIME types", + "STORAGE_BUCKET_NAME_LABEL": "Bucket Name", + "STORAGE_BUCKET_NAME_REQUIRED": "Bucket Name is required", + "STORAGE_EDIT": "Edit Storage Provider", + "STORAGE_ENDPOINT_LABEL": "Endpoint", + "STORAGE_FIELD_DEFAULT": "Default", + "STORAGE_FIELD_REGION": "Region", + "STORAGE_FIELD_TYPE": "Type", + "STORAGE_HEADER": "PlaceOS Data Stores", + "STORAGE_IS_DEFAULT_LABEL": "Is Default", + "STORAGE_LIST_EMPTY": "No storage providers for {{ item }} domain", + "STORAGE_LOADING": "Loading storage providers...", + "STORAGE_NEW": "New Storage Provider", + "STORAGE_REGION_LABEL": "Region", + "STORAGE_REGION_REQUIRED": "Region is required", + "STORAGE_REMOVE": "Remove Storage Provider", + "STORAGE_REMOVE_LOADING": "Removing storage provider...", + "STORAGE_REMOVE_MSG": "Are you sure you want to remove the storage provider for {{ type }} {{name}}?", + "STORAGE_REMOVE_TITLE": "Remove Storage Provider", + "STORAGE_SAVE_ERROR": "Failed to save storage provider.", + "STORAGE_SAVE_SUCCESS": "Successfully saved storage provider.", + "STORAGE_SECRET_LABEL": "Access Secret", + "STORAGE_SECRET_REQUIRED": "Access Secret is required", + "TAB_ABOUT": "About", + "TAB_API_KEYS": "Application Keys", + "TAB_BUILD_JOBS": "Build Jobs", + "TAB_CLUSTERS": "Clusters", + "TAB_CUSTOM_SCHEMAS": "Custom Schemas", + "TAB_DATABASE": "Database", + "TAB_EDGES": "Edges", + "TAB_EXTENSIONS": "Extensions", + "TAB_INTERFACES": "Interfaces", + "TAB_MQTT_BROKERS": "MQTT Brokers", + "TAB_RESOURCE_IMPORTS": "Resource Imports", + "TAB_SIGNAGE_AI": "Signage AI", + "TAB_SIGNAGE_PLUGINS": "Signage Plugins", + "TAB_TENANT_CONFIG": "Staff Tenants", + "TAB_UPLOADS_LIBRARY": "Uploads Library", + "TAB_UPLOAD_STORAGE": "Data Stores", + "TENANTS_ADD": "Add Tenant", + "TENANTS_APP_DOMAIN": "Outlook App Domain", + "TENANTS_APP_DOMAIN_REQUIRED": "An application domain is required", + "TENANTS_APP_ID": "Outlook App ID", + "TENANTS_APP_ID_REQUIRED": "An application ID is required", + "TENANTS_APP_RESOURCE": "Outlook App Resource", + "TENANTS_APP_RESOURCE_REQUIRED": "An application resource is required", + "TENANTS_BASE_PATH": "Outlook Base Path", + "TENANTS_BASE_PATH_REQUIRED": "Base path to the application root folder is required", + "TENANTS_BOOKING_LIMITS": "Booking Limits", + "TENANTS_CONFIG_OUTLOOK": "Configure Outlook Plugin", + "TENANTS_DELEGATED": "Delegated", + "TENANTS_EARLY_CHECKIN": "Early Check-in before Meeting", + "TENANTS_EDIT": "Edit Tenant", + "TENANTS_EDIT_BOOKING_LIMITS": "Edit Booking Limits", + "TENANTS_EMAIL_DOMAIN": "Email Domain", + "TENANTS_EMAIL_ERROR": "A domain is required", + "TENANTS_EMAIL_PLACEHOLDER": "Domain of user's email for this tenant", + "TENANTS_EMPTY": "No tenants for the selected domain", + "TENANTS_FIELD_NAME": "Tenant name", + "TENANTS_HEADER": "Staff API Tenants", + "TENANTS_HOURS": "{{ hour }} hours", + "TENANTS_ITEM_REQUIRED": "A {{ name }} is required", + "TENANTS_MINUTES": "{{ minute }} minutes", + "TENANTS_MINUTES_HOURS": "{{ hour }} hours {{ minute }} minutes", + "TENANTS_NAME_ERROR": "A tenant name is required", + "TENANTS_NEW": "New Tenant", + "TENANTS_PLATFORM": "Platform", + "TENANTS_REMOVE": "Remove Tenant", + "TENANTS_SAVE_ERROR": "Failed to update tenant.", + "TENANTS_SAVE_SUCCESS": "Successfully updated tenant.", + "TENANTS_SECRET_EXPIRY": "Expires", + "TENANTS_SERVICE_ACCOUNT": "Service Account", + "TENANTS_SERVICE_ACCOUNT_ERROR": "Service account should be a valid email address", + "TENANTS_SOURCE_LOCATION": "Outlook Source Location", + "TENANTS_SOURCE_LOCATION_REQUIRED": "A source location is required", + "TITLE": "Admin", + "UPLOADS_LIB_COPY": "Copy Upload URL", + "UPLOADS_LIB_DOWNLOAD": "Download Upload", + "UPLOADS_LIB_DOWNLOAD_ERROR": "Failed to download upload. Error: {{ error }}", + "UPLOADS_LIB_FIELD_SIZE": "Size", + "UPLOADS_LIB_FIELD_TYPE": "File Type", + "UPLOADS_LIB_HEADER": "Uploads Library", + "UPLOADS_LIB_LIST_EMPTY": "No uploads for the selected domain", + "UPLOADS_LIB_REMOVE": "Remove Upload", + "UPLOADS_LIB_REMOVE_LOADING": "Removing upload...", + "UPLOADS_LIB_REMOVE_MSG": "

Are you sure you want remove the upload \"{{ filename }}\"?

The upload will be deleted immediately.

", + "UPLOADS_LIB_VIEW": "View Upload", + "VIEW_CHANGELOG": "View Changelog" }, "CASCADE": { "RESOLVING": "Working out what would be removed...", diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 1c06048d3..2e36b3847 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -175,6 +175,11 @@ export class PlaceComponent extends AsyncHandler implements OnInit { name: i18n('ADMIN.TAB_UPLOADS_LIBRARY'), icon: { value: 'photo_album' }, }, + { + id: 'signage-ai', + name: i18n('ADMIN.TAB_SIGNAGE_AI'), + icon: { value: 'auto_awesome' }, + }, { id: 'signage-plugins', name: i18n('ADMIN.TAB_SIGNAGE_PLUGINS'), diff --git a/src/app/admin/admin.routes.ts b/src/app/admin/admin.routes.ts index bec795362..b2f8ab05b 100644 --- a/src/app/admin/admin.routes.ts +++ b/src/app/admin/admin.routes.ts @@ -107,6 +107,13 @@ export const ROUTES: Routes = [ (m) => m.StorageComponent, ), }, + { + path: 'signage-ai', + loadComponent: () => + import('./signage-ai/signage-ai.component').then( + (m) => m.SignageAIComponent, + ), + }, { path: 'upload-library', loadComponent: () => diff --git a/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts b/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts new file mode 100644 index 000000000..342bfcd1a --- /dev/null +++ b/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts @@ -0,0 +1,287 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { form, FormField, required, submit } from '@angular/forms/signals'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { i18n } from '../../common/locale.service'; +import { notifyError, notifySuccess } from '../../common/notifications'; +import { FullscreenModalShellComponent } from '../../ui/fullscreen-modal-shell.component'; +import { SettingsToggleComponent } from '../../ui/settings-toggle.component'; +import { TranslatePipe } from '../../ui/translate.pipe'; +import { saveSignageAIProvider, SignageAIProvider } from './signage-ai.fn'; + +/** + * Credentials differ per vendor, so the form swaps the middle section rather + * than showing every field at once. They are only ever sent, never returned: + * on an edit the boxes start empty and staying empty keeps what is stored. + */ +@Component({ + selector: 'signage-ai-provider-modal', + template: ` + +
+
+ + + + {{ + 'ADMIN.AI_PROVIDER_NAME_REQUIRED' | translate + }} + +
+
+ + + + OpenAI + Azure OpenAI + Google (Vertex) + + +
+ + @if (is_google()) { +
+ + + + +
+
+ + + + +
+
+ + + + +
+ } @else { +
+ + + + +
+ @if (is_azure()) { +
+
+ + + + +
+
+ + + + +
+
+ } + } + +
+ + + + +
+
+ + + + +
+
+
+ + + + +
+
+ + + + +
+
+ {{ + 'ADMIN.AI_PROVIDER_ENABLED' | translate + }} + {{ + 'ADMIN.AI_PROVIDER_IS_DEFAULT' | translate + }} +
+
+ `, + styles: [``], + imports: [ + FullscreenModalShellComponent, + SettingsToggleComponent, + TranslatePipe, + MatFormFieldModule, + MatSelectModule, + FormField, + MatInputModule, + ], +}) +export class SignageAIProviderModalComponent { + private _data = inject<{ item?: SignageAIProvider; domain?: string }>( + MAT_DIALOG_DATA, + ); + private _dialog_ref = inject( + MatDialogRef, + ); + + public readonly item = this._data.item; + public readonly loading = signal(''); + + public readonly formModel = signal({ + id: this._data.item?.id || '', + authority_id: this._data.item?.authority_id ?? this._data.domain ?? '', + name: this._data.item?.name || '', + provider: this._data.item?.provider || 'OPENAI', + endpoint: this._data.item?.endpoint || '', + default_model: this._data.item?.default_model || 'gpt-image-2', + enabled: this._data.item?.enabled ?? true, + is_default: this._data.item?.is_default ?? false, + user_per_day: this._data.item?.quotas?.user_per_day ?? 60, + domain_per_month: this._data.item?.quotas?.domain_per_month ?? 2000, + // credentials, never returned by the API + api_key: '', + deployment: '', + api_version: '', + project_id: '', + client_email: '', + private_key: '', + }); + + public readonly form = form(this.formModel, (path) => { + required(path.name); + required(path.provider); + }); + + public readonly is_google = computed( + () => this.formModel().provider === 'GOOGLE_VERTEX', + ); + public readonly is_azure = computed( + () => this.formModel().provider === 'AZURE_OPENAI', + ); + + public async save() { + await submit(this.form, async () => undefined); + if (this.form().invalid()) return; + + const model = this.formModel(); + const credentials = this._credentials(model); + if (!model.id && !Object.keys(credentials).length) { + notifyError(i18n('ADMIN.AI_PROVIDER_CREDENTIALS_REQUIRED')); + return; + } + + this.loading.set(i18n('ADMIN.AI_PROVIDER_SAVING')); + this._dialog_ref.disableClose = true; + + const body: any = { + id: model.id || undefined, + name: model.name, + provider: model.provider, + authority_id: model.authority_id || null, + endpoint: model.endpoint || null, + default_model: model.default_model || null, + enabled: model.enabled, + is_default: model.is_default, + quotas: { + user_per_day: +model.user_per_day, + domain_per_month: +model.domain_per_month, + }, + }; + // an edit that leaves the boxes empty keeps the stored credentials + if (Object.keys(credentials).length) body.credentials = credentials; + + await saveSignageAIProvider(body).catch((error) => { + notifyError(i18n('ADMIN.AI_PROVIDER_SAVE_ERROR')); + this.loading.set(''); + this._dialog_ref.disableClose = false; + throw error; + }); + + this.loading.set(''); + this._dialog_ref.disableClose = false; + notifySuccess(i18n('ADMIN.AI_PROVIDER_SAVE_SUCCESS')); + this._dialog_ref.close(true); + } + + private _credentials(model: ReturnType) { + const out: Record = {}; + if (model.provider === 'GOOGLE_VERTEX') { + if (model.project_id) out.project_id = model.project_id; + if (model.client_email) out.client_email = model.client_email; + if (model.private_key) out.private_key = model.private_key; + } else { + if (model.api_key) out.api_key = model.api_key; + if (model.deployment) out.deployment = model.deployment; + if (model.api_version) out.api_version = model.api_version; + } + return out; + } +} diff --git a/src/app/admin/signage-ai/signage-ai.component.ts b/src/app/admin/signage-ai/signage-ai.component.ts new file mode 100644 index 000000000..dd5263e6e --- /dev/null +++ b/src/app/admin/signage-ai/signage-ai.component.ts @@ -0,0 +1,310 @@ +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { i18n } from '../../common/locale.service'; +import { notifyError, notifySuccess } from '../../common/notifications'; +import { openConfirmModal } from '../../overlays/confirm-modal.component'; +import { IconComponent } from '../../ui/icon.component'; +import { DateFromPipe } from '../../ui/pipes/date-from.pipe'; +import { SimpleTableComponent } from '../../ui/simple-table.component'; +import { TranslatePipe } from '../../ui/translate.pipe'; +import { AdminDataService } from '../admin-data.service'; +import { SignageAIProviderModalComponent } from './signage-ai-provider-modal.component'; +import { + querySignageAIProviders, + removeSignageAIProvider, + SignageAIProvider, + SignageAIUsageRow, + signageAIUsage, + testSignageAIProvider, +} from './signage-ai.fn'; + +@Component({ + selector: 'app-signage-ai', + template: ` +
+
+
+ {{ 'ADMIN.AI_PROVIDERS_HEADER' | translate }} +
+
+ + + {{ + 'ADMIN.ALL_DOMAINS' | translate + }} + @for (option of domain_list(); track option.id) { + + {{ option.name }} + + } + + + +
+
+
+ + + +
+ {{ 'ADMIN.AI_USAGE_HEADER' | translate }} +
+

+ {{ 'ADMIN.AI_USAGE_HINT' | translate }} +

+ +
+
+ +
+
{{ row.name }}
+
+ {{ row.domain?.name || row.authority_id || 'Any domain' }} +
+
+
+ +
{{ data }}
+
+ +
{{ data }}
+
+ +
{{ +data * 1000 | dateFrom }}
+
+ +
+
+ {{ data ? 'check' : 'close' }} +
+
+
+ +
+ + + +
+
+ `, + styles: [``], + imports: [ + IconComponent, + MatRippleModule, + TranslatePipe, + MatTooltipModule, + SimpleTableComponent, + MatProgressBarModule, + MatFormFieldModule, + MatSelectModule, + FormsModule, + DateFromPipe, + ], +}) +export class SignageAIComponent implements OnInit { + private _dialog = inject(MatDialog); + private _admin_data = inject(AdminDataService); + + public readonly loading = signal(''); + public readonly providers = signal([]); + public readonly usage = signal([]); + public readonly domain_list = this._admin_data.domain_list; + public readonly domain = this._admin_data.selectedDomain('signage-ai'); + + public readonly provider_list = computed(() => + this.providers().map((provider) => ({ + ...provider, + domain: this.domain_list().find( + (d) => d.id === provider.authority_id, + ), + })), + ); + + public async ngOnInit() { + this.loading.set(i18n('ADMIN.AI_PROVIDERS_LOADING')); + await this._admin_data.loadDomains(); + await this.load(); + this.loading.set(''); + } + + public edit(item?: SignageAIProvider) { + const ref = this._dialog.open(SignageAIProviderModalComponent, { + data: { item, domain: this.domain()?.id }, + }); + ref.afterClosed().subscribe(() => this.load()); + } + + public async test(item: SignageAIProvider) { + this.loading.set(i18n('ADMIN.AI_PROVIDER_TESTING')); + const result = await testSignageAIProvider(item.id).catch(() => null); + this.loading.set(''); + if (result?.ok) { + notifySuccess( + i18n('ADMIN.AI_PROVIDER_TEST_OK', { + ms: `${result.latency_ms}`, + }), + ); + } else { + notifyError( + result?.error || i18n('ADMIN.AI_PROVIDER_TEST_FAILED'), + ); + } + } + + public async remove(item: SignageAIProvider) { + const resp = await openConfirmModal( + { + title: i18n('ADMIN.AI_PROVIDER_REMOVE_TITLE'), + content: i18n('ADMIN.AI_PROVIDER_REMOVE_MSG', { + name: item.name, + }), + icon: { content: 'delete_forever' }, + }, + this._dialog, + ); + if (resp.reason !== 'done') return; + resp.loading(i18n('ADMIN.AI_PROVIDER_REMOVING')); + await removeSignageAIProvider(item.id); + resp.close(); + this.load(); + } + + public async load() { + this.loading.set(i18n('ADMIN.AI_PROVIDERS_LOADING')); + const authority_id = this.domain()?.id; + this.providers.set( + await querySignageAIProviders({ authority_id }).catch(() => []), + ); + this.usage.set(await signageAIUsage().catch(() => [])); + this.loading.set(''); + } +} diff --git a/src/app/admin/signage-ai/signage-ai.fn.ts b/src/app/admin/signage-ai/signage-ai.fn.ts new file mode 100644 index 000000000..5aac6b930 --- /dev/null +++ b/src/app/admin/signage-ai/signage-ai.fn.ts @@ -0,0 +1,85 @@ +import { apiEndpoint, del, get, patch, post } from '@placeos/ts-client'; + +const PATH = () => `${apiEndpoint()}/signage/ai`; + +export class SignageAIProvider { + public readonly id: string; + public readonly name: string; + /** OPENAI | AZURE_OPENAI | GOOGLE_VERTEX */ + public readonly provider: string; + public readonly authority_id: string | null; + public readonly endpoint: string | null; + public readonly location: string | null; + public readonly default_model: string | null; + public readonly allowed_models: string[]; + public readonly enabled: boolean; + public readonly is_default: boolean; + public readonly quotas: Record; + public readonly created_at: number; + public readonly updated_at: number; + + constructor(item: Partial = {}) { + Object.assign(this, item); + } +} + +export interface SignageAIUsageRow { + provider: string; + model: string; + jobs: number; + candidates: number; + images_produced: number; + cost_units: number; +} + +export interface SignageAITestResult { + ok: boolean; + latency_ms: number; + model?: string; + error?: string; + kind?: string; +} + +function query(params: Record) { + const pairs = Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`); + return pairs.length ? `?${pairs.join('&')}` : ''; +} + +export async function querySignageAIProviders(params: { + authority_id?: string; +} = {}): Promise { + const list = (await get( + `${PATH()}/providers${query(params)}`, + )) as unknown as Partial[]; + return (list || []).map((item) => new SignageAIProvider(item)); +} + +export async function saveSignageAIProvider( + item: Partial & { credentials?: Record }, +): Promise { + const { id, ...body } = item; + const result = id + ? await patch(`${PATH()}/providers/${encodeURIComponent(id)}`, body) + : await post(`${PATH()}/providers`, body); + return new SignageAIProvider(result as any); +} + +export function removeSignageAIProvider(id: string) { + return del(`${PATH()}/providers/${encodeURIComponent(id)}`); +} + +/** Prove the credentials work: one small image, discarded. */ +export function testSignageAIProvider(id: string) { + return post( + `${PATH()}/providers/${encodeURIComponent(id)}/test`, + {}, + ) as unknown as Promise; +} + +export function signageAIUsage(params: { from?: number; to?: number } = {}) { + return get( + `${PATH()}/usage${query(params)}`, + ) as unknown as Promise; +} From 0a1d2df5c63e29e4fe957afc9b7b9b9f79913c51 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Fri, 28 Aug 2026 09:39:24 +1000 Subject: [PATCH 2/7] fix(admin): send a cleared provider field as empty, not null null means leave it alone, empty means unset it. Clearing the endpoint in the form sent null, so the old value survived the save and the provider kept talking to whatever it was pointed at. Co-Authored-By: Claude Opus 5 (1M context) --- .../admin/signage-ai/signage-ai-provider-modal.component.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts b/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts index 342bfcd1a..1f15218e2 100644 --- a/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts +++ b/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts @@ -246,8 +246,10 @@ export class SignageAIProviderModalComponent { name: model.name, provider: model.provider, authority_id: model.authority_id || null, - endpoint: model.endpoint || null, - default_model: model.default_model || null, + // sent as an empty string rather than null when cleared: null means + // "leave it alone", empty means "unset it" + endpoint: model.endpoint, + default_model: model.default_model, enabled: model.enabled, is_default: model.is_default, quotas: { From bc4ee3d08bd9ba5ffeea55e680c559bf5d752cbe Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Mon, 31 Aug 2026 13:33:47 +1000 Subject: [PATCH 3/7] fix(admin): signage AI provider tab defects Testing a provider asks the vendor for a real image and is billed. The button had no disabled state and no confirmation, so a second click on a slow answer spent again. It now says what it costs and refuses a second run. A failed delete left the confirm dialog spinning for good. The domain picker is gone: the API answers for the domain this Backoffice is served from, and another customer's row is deliberately unreachable, so the picker was choosing between one option and a lie. A Google Vertex row needs a region and there was no field for one, so it could not be configured through the UI at all. --- public/assets/locale/en-AU.json | 5 ++ .../signage-ai-provider-modal.component.ts | 20 ++++- .../admin/signage-ai/signage-ai.component.ts | 85 ++++++++++++------- src/app/admin/signage-ai/signage-ai.fn.ts | 19 +++-- 4 files changed, 87 insertions(+), 42 deletions(-) diff --git a/public/assets/locale/en-AU.json b/public/assets/locale/en-AU.json index df1005b39..0768aae69 100644 --- a/public/assets/locale/en-AU.json +++ b/public/assets/locale/en-AU.json @@ -918,6 +918,7 @@ "AI_PROVIDERS_EMPTY": "No AI provider is configured. Signage image generation stays hidden until one is added.", "AI_PROVIDERS_HEADER": "Signage AI providers", "AI_PROVIDERS_LOADING": "Loading providers...", + "AI_PROVIDERS_SCOPE": "Providers and usage for this domain. A provider belonging to another domain is not shown here.", "AI_PROVIDER_ADD": "Add provider", "AI_PROVIDER_API_KEY": "API key", "AI_PROVIDER_API_VERSION": "API version", @@ -929,6 +930,7 @@ "AI_PROVIDER_ENABLED": "Enabled", "AI_PROVIDER_ENDPOINT": "Endpoint (optional)", "AI_PROVIDER_IS_DEFAULT": "Default", + "AI_PROVIDER_LOCATION": "Region (Google Vertex only)", "AI_PROVIDER_MODEL": "Default model", "AI_PROVIDER_NAME": "Name", "AI_PROVIDER_NAME_REQUIRED": "A name is required", @@ -936,6 +938,7 @@ "AI_PROVIDER_PRIVATE_KEY": "Service account private key", "AI_PROVIDER_PROJECT": "Google project id", "AI_PROVIDER_REMOVE": "Remove provider", + "AI_PROVIDER_REMOVE_FAILED": "That provider could not be removed", "AI_PROVIDER_REMOVE_MSG": "Remove {{ name }}? Artwork already generated is kept, but no new images can be made until another provider is configured.", "AI_PROVIDER_REMOVE_TITLE": "Remove AI provider", "AI_PROVIDER_REMOVING": "Removing provider...", @@ -945,7 +948,9 @@ "AI_PROVIDER_TEST": "Test credentials", "AI_PROVIDER_TESTING": "Asking the vendor for a test image...", "AI_PROVIDER_TEST_FAILED": "The vendor did not answer", + "AI_PROVIDER_TEST_MSG": "This asks {{ name }} for one small image, which the vendor bills for. The image is discarded.", "AI_PROVIDER_TEST_OK": "Working, answered in {{ ms }} ms", + "AI_PROVIDER_TEST_TITLE": "Test this provider?", "AI_PROVIDER_USER_QUOTA": "Images per person per day", "AI_PROVIDER_VENDOR": "Vendor", "AI_USAGE_ASKED": "Images asked for", diff --git a/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts b/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts index 1f15218e2..9a9ab07c1 100644 --- a/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts +++ b/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts @@ -130,6 +130,20 @@ import { saveSignageAIProvider, SignageAIProvider } from './signage-ai.fn'; + +
+ + + + +