fix: OIDC and API Key management issues (#1642)

This commit is contained in:
Eli Bosley
2025-09-03 09:47:11 -04:00
committed by GitHub
parent a8e4119270
commit 0fe2c2c1c8
10 changed files with 556 additions and 82 deletions

View File

@@ -1,5 +1,5 @@
{
"version": "4.17.0",
"version": "4.18.0",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],

View File

@@ -0,0 +1,252 @@
---
title: Programmatic API Key Management
description: Create, use, and delete API keys programmatically for automated workflows
sidebar_position: 4
---
# Programmatic API Key Management
This guide explains how to create, use, and delete API keys programmatically using the Unraid API CLI, enabling automated workflows and scripts.
## Overview
The `unraid-api apikey` command supports both interactive and non-interactive modes, making it suitable for:
- Automated deployment scripts
- CI/CD pipelines
- Temporary access provisioning
- Infrastructure as code workflows
:::tip[Quick Start]
Jump to the [Complete Workflow Example](#complete-workflow-example) to see everything in action.
:::
## Creating API Keys Programmatically
### Basic Creation with JSON Output
Use the `--json` flag to get machine-readable output:
```bash
unraid-api apikey --create --name "workflow key" --roles ADMIN --json
```
**Output:**
```json
{
"key": "your-generated-api-key-here",
"name": "workflow key",
"id": "generated-uuid"
}
```
### Advanced Creation with Permissions
```bash
unraid-api apikey --create \
--name "limited access key" \
--permissions "DOCKER:READ_ANY,ARRAY:READ_ANY" \
--description "Read-only access for monitoring" \
--json
```
### Handling Existing Keys
If a key with the same name exists, use `--overwrite`:
```bash
unraid-api apikey --create --name "existing key" --roles ADMIN --overwrite --json
```
:::warning[Key Replacement]
The `--overwrite` flag will permanently replace the existing key. The old key will be immediately invalidated.
:::
## Deleting API Keys Programmatically
### Non-Interactive Deletion
Delete a key by name without prompts:
```bash
unraid-api apikey --delete --name "workflow key"
```
**Output:**
```
Successfully deleted 1 API key
```
### JSON Output for Deletion
Use `--json` flag for machine-readable delete confirmation:
```bash
unraid-api apikey --delete --name "workflow key" --json
```
**Success Output:**
```json
{
"deleted": 1,
"keys": [
{
"id": "generated-uuid",
"name": "workflow key"
}
]
}
```
**Error Output:**
```json
{
"deleted": 0,
"error": "No API key found with name: nonexistent key"
}
```
### Error Handling
When the specified key doesn't exist:
```bash
unraid-api apikey --delete --name "nonexistent key"
# Output: No API keys found to delete
```
**JSON Error Output:**
```json
{
"deleted": 0,
"message": "No API keys found to delete"
}
```
## Complete Workflow Example
Here's a complete example for temporary access provisioning:
```bash
#!/bin/bash
set -e
# 1. Create temporary API key
echo "Creating temporary API key..."
KEY_DATA=$(unraid-api apikey --create \
--name "temp deployment key" \
--roles ADMIN \
--description "Temporary key for deployment $(date)" \
--json)
# 2. Extract the API key
API_KEY=$(echo "$KEY_DATA" | jq -r '.key')
echo "API key created successfully"
# 3. Use the key for operations
echo "Configuring services..."
curl -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider": "azure", "clientId": "your-client-id"}' \
http://localhost:3001/graphql
# 4. Clean up (always runs, even on error)
trap 'echo "Cleaning up..."; unraid-api apikey --delete --name "temp deployment key"' EXIT
echo "Deployment completed successfully"
```
## Command Reference
### Create Command Options
| Flag | Description | Example |
| ----------------------- | ----------------------- | --------------------------------- |
| `--name <name>` | Key name (required) | `--name "my key"` |
| `--roles <roles>` | Comma-separated roles | `--roles ADMIN,VIEWER` |
| `--permissions <perms>` | Resource:action pairs | `--permissions "DOCKER:READ_ANY"` |
| `--description <desc>` | Key description | `--description "CI/CD key"` |
| `--overwrite` | Replace existing key | `--overwrite` |
| `--json` | Machine-readable output | `--json` |
### Available Roles
- `ADMIN` - Full system access
- `CONNECT` - Unraid Connect features
- `VIEWER` - Read-only access
- `GUEST` - Limited access
### Available Resources and Actions
**Resources:** `ACTIVATION_CODE`, `API_KEY`, `ARRAY`, `CLOUD`, `CONFIG`, `CONNECT`, `CONNECT__REMOTE_ACCESS`, `CUSTOMIZATIONS`, `DASHBOARD`, `DISK`, `DISPLAY`, `DOCKER`, `FLASH`, `INFO`, `LOGS`, `ME`, `NETWORK`, `NOTIFICATIONS`, `ONLINE`, `OS`, `OWNER`, `PERMISSION`, `REGISTRATION`, `SERVERS`, `SERVICES`, `SHARE`, `VARS`, `VMS`, `WELCOME`
**Actions:** `CREATE_ANY`, `CREATE_OWN`, `READ_ANY`, `READ_OWN`, `UPDATE_ANY`, `UPDATE_OWN`, `DELETE_ANY`, `DELETE_OWN`
### Delete Command Options
| Flag | Description | Example |
| --------------- | ------------------------ | ----------------- |
| `--delete` | Enable delete mode | `--delete` |
| `--name <name>` | Key to delete (optional) | `--name "my key"` |
**Note:** If `--name` is omitted, the command runs interactively.
## Best Practices
:::info[Security Best Practices]
**Minimal Permissions**
- Use specific permissions instead of ADMIN role when possible
- Example: `--permissions "DOCKER:READ_ANY"` instead of `--roles ADMIN`
**Key Lifecycle Management**
- Always clean up temporary keys after use
- Store API keys securely (environment variables, secrets management)
- Use descriptive names and descriptions for audit trails
:::
### Error Handling
- Check exit codes (`$?`) after each command
- Use `set -e` in bash scripts to fail fast
- Implement proper cleanup with `trap`
### Key Naming
- Use descriptive names that include purpose and date
- Names must contain only letters, numbers, and spaces
- Unicode letters are supported
## Troubleshooting
### Common Issues
:::note[Common Error Messages]
**"API key name must contain only letters, numbers, and spaces"**
- **Solution:** Remove special characters like hyphens, underscores, or symbols
**"API key with name 'x' already exists"**
- **Solution:** Use `--overwrite` flag or choose a different name
**"Please add at least one role or permission to the key"**
- **Solution:** Specify either `--roles` or `--permissions` (or both)
:::
### Debug Mode
For troubleshooting, run with debug logging:
```bash
LOG_LEVEL=debug unraid-api apikey --create --name "debug key" --roles ADMIN
```

View File

@@ -282,4 +282,153 @@ describe('ApiKeyCommand', () => {
expect(result).toEqual([Role.ADMIN, Role.CONNECT]);
});
});
describe('JSON output functionality', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
it('should output JSON when creating key with --json flag', async () => {
const mockKey = {
id: 'test-id-123',
key: 'test-key-456',
name: 'JSON_TEST',
roles: [Role.ADMIN],
createdAt: new Date().toISOString(),
permissions: [],
};
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
await command.run([], {
name: 'JSON_TEST',
create: true,
roles: [Role.ADMIN],
json: true,
});
expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify({ key: 'test-key-456', name: 'JSON_TEST', id: 'test-id-123' })
);
expect(logService.log).not.toHaveBeenCalledWith('test-key-456');
});
it('should output JSON when fetching existing key with --json flag', async () => {
const existingKey = {
id: 'existing-id-456',
key: 'existing-key-789',
name: 'EXISTING_JSON',
roles: [Role.VIEWER],
createdAt: new Date().toISOString(),
permissions: [],
};
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(existingKey);
await command.run([], {
name: 'EXISTING_JSON',
create: false,
json: true,
});
expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify({ key: 'existing-key-789', name: 'EXISTING_JSON', id: 'existing-id-456' })
);
expect(logService.log).not.toHaveBeenCalledWith('existing-key-789');
});
it('should output JSON when deleting key with --json flag', async () => {
const existingKeys = [
{
id: 'delete-id-123',
name: 'DELETE_JSON',
key: 'delete-key-456',
roles: [Role.GUEST],
createdAt: new Date().toISOString(),
permissions: [],
},
];
vi.spyOn(apiKeyService, 'findAll').mockResolvedValue(existingKeys);
vi.spyOn(apiKeyService, 'deleteApiKeys').mockResolvedValue();
await command.run([], {
name: 'DELETE_JSON',
delete: true,
json: true,
});
expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify({
deleted: 1,
keys: [{ id: 'delete-id-123', name: 'DELETE_JSON' }],
})
);
expect(logService.log).not.toHaveBeenCalledWith('Successfully deleted 1 API key');
});
it('should output JSON error when deleting non-existent key with --json flag', async () => {
vi.spyOn(apiKeyService, 'findAll').mockResolvedValue([]);
await command.run([], {
name: 'NONEXISTENT',
delete: true,
json: true,
});
expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify({ deleted: 0, message: 'No API keys found to delete' })
);
expect(logService.log).not.toHaveBeenCalledWith('No API keys found to delete');
});
it('should not suppress creation message when not using JSON', async () => {
const mockKey = {
id: 'test-id',
key: 'test-key',
name: 'NO_JSON_TEST',
roles: [Role.ADMIN],
createdAt: new Date().toISOString(),
permissions: [],
};
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
await command.run([], {
name: 'NO_JSON_TEST',
create: true,
roles: [Role.ADMIN],
json: false,
});
expect(logService.log).toHaveBeenCalledWith('Creating API Key...');
expect(logService.log).toHaveBeenCalledWith('test-key');
expect(consoleSpy).not.toHaveBeenCalled();
});
it('should suppress creation message when using JSON', async () => {
const mockKey = {
id: 'test-id',
key: 'test-key',
name: 'JSON_SUPPRESS_TEST',
roles: [Role.ADMIN],
createdAt: new Date().toISOString(),
permissions: [],
};
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
await command.run([], {
name: 'JSON_SUPPRESS_TEST',
create: true,
roles: [Role.ADMIN],
json: true,
});
expect(logService.log).not.toHaveBeenCalledWith('Creating API Key...');
expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify({ key: 'test-key', name: 'JSON_SUPPRESS_TEST', id: 'test-id' })
);
});
});
});

View File

@@ -10,12 +10,13 @@ import { Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.mode
interface KeyOptions {
name: string;
create: boolean;
create?: boolean;
delete?: boolean;
description?: string;
roles?: Role[];
permissions?: Permission[];
overwrite?: boolean;
json?: boolean;
}
@Command({
@@ -100,46 +101,102 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`,
return true;
}
/** Prompt the user to select API keys to delete. Then, delete the selected keys. */
private async deleteKeys() {
@Option({
flags: '--json',
description: 'Output machine-readable JSON format',
})
parseJson(): boolean {
return true;
}
/** Helper to output either JSON or regular log message */
private output(message: string, jsonData?: object, jsonOutput?: boolean): void {
if (jsonOutput && jsonData) {
console.log(JSON.stringify(jsonData));
} else {
this.logger.log(message);
}
}
/** Helper to output either JSON or regular error message */
private outputError(message: string, jsonData?: object, jsonOutput?: boolean): void {
if (jsonOutput && jsonData) {
console.log(JSON.stringify(jsonData));
} else {
this.logger.error(message);
}
}
/** Delete API keys either by name (non-interactive) or by prompting user selection (interactive). */
private async deleteKeys(name?: string, jsonOutput?: boolean) {
const allKeys = await this.apiKeyService.findAll();
if (allKeys.length === 0) {
this.logger.log('No API keys found to delete');
this.output(
'No API keys found to delete',
{ deleted: 0, message: 'No API keys found to delete' },
jsonOutput
);
return;
}
const answers = await this.inquirerService.prompt<DeleteApiKeyAnswers>(
DeleteApiKeyQuestionSet.name,
{}
);
if (!answers.selectedKeys || answers.selectedKeys.length === 0) {
this.logger.log('No keys selected for deletion');
return;
let selectedKeyIds: string[];
let deletedKeys: { id: string; name: string }[] = [];
if (name) {
// Non-interactive mode: delete by name
const keyToDelete = allKeys.find((key) => key.name === name);
if (!keyToDelete) {
this.outputError(
`No API key found with name: ${name}`,
{ deleted: 0, error: `No API key found with name: ${name}` },
jsonOutput
);
process.exit(1);
}
selectedKeyIds = [keyToDelete.id];
deletedKeys = [{ id: keyToDelete.id, name: keyToDelete.name }];
} else {
// Interactive mode: prompt user to select keys
const answers = await this.inquirerService.prompt<DeleteApiKeyAnswers>(
DeleteApiKeyQuestionSet.name,
{}
);
if (!answers.selectedKeys || answers.selectedKeys.length === 0) {
this.output(
'No keys selected for deletion',
{ deleted: 0, message: 'No keys selected for deletion' },
jsonOutput
);
return;
}
selectedKeyIds = answers.selectedKeys;
deletedKeys = allKeys
.filter((key) => selectedKeyIds.includes(key.id))
.map((key) => ({ id: key.id, name: key.name }));
}
try {
await this.apiKeyService.deleteApiKeys(answers.selectedKeys);
this.logger.log(`Successfully deleted ${answers.selectedKeys.length} API keys`);
await this.apiKeyService.deleteApiKeys(selectedKeyIds);
const message = `Successfully deleted ${selectedKeyIds.length} API key${selectedKeyIds.length === 1 ? '' : 's'}`;
this.output(message, { deleted: selectedKeyIds.length, keys: deletedKeys }, jsonOutput);
} catch (error) {
this.logger.error(error as any);
const errorMessage = error instanceof Error ? error.message : String(error);
this.outputError(errorMessage, { deleted: 0, error: errorMessage }, jsonOutput);
process.exit(1);
}
}
async run(
_: string[],
options: KeyOptions = { create: false, name: '', delete: false }
): Promise<void> {
async run(_: string[], options: KeyOptions = { name: '', delete: false }): Promise<void> {
try {
if (options.delete) {
await this.deleteKeys();
await this.deleteKeys(options.name, options.json);
return;
}
const key = this.apiKeyService.findByField('name', options.name);
if (key) {
this.logger.log(key.key);
} else if (options.create) {
this.output(key.key, { key: key.key, name: key.name, id: key.id }, options.json);
} else if (options.create === true) {
// Check if we have minimum required info from flags (name + at least one role or permission)
const hasMinimumInfo =
options.name &&
@@ -153,14 +210,20 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`,
// Non-interactive mode - check if key exists and handle overwrite
const existingKey = this.apiKeyService.findByField('name', options.name);
if (existingKey && !options.overwrite) {
this.logger.error(
`API key with name '${options.name}' already exists. Use --overwrite to replace it.`
this.outputError(
`API key with name '${options.name}' already exists. Use --overwrite to replace it.`,
{
error: `API key with name '${options.name}' already exists. Use --overwrite to replace it.`,
},
options.json
);
process.exit(1);
}
}
this.logger.log('Creating API Key...');
if (!options.json) {
this.logger.log('Creating API Key...');
}
if (!options.roles && !options.permissions) {
this.logger.error('Please add at least one role or permission to the key.');
@@ -178,7 +241,7 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`,
overwrite: options.overwrite ?? false,
});
this.logger.log(key.key);
this.output(key.key, { key: key.key, name: key.name, id: key.id }, options.json);
} else {
this.logger.log('No Key Found');
process.exit(1);

View File

@@ -194,25 +194,31 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
provider.authorizationRules = rules;
}
// Validate that authorization rules are present and valid for ALL providers
// Skip providers without authorization rules (they will be ignored)
if (!provider.authorizationRules || provider.authorizationRules.length === 0) {
throw new Error(
`Provider "${provider.name}" requires authorization rules. Please configure who can access your server.`
this.logger.warn(
`Provider "${provider.name}" has no authorization rules and will be ignored. Configure authorization rules to enable this provider.`
);
}
// Validate each rule has valid values
for (const rule of provider.authorizationRules) {
if (!rule.claim || !rule.claim.trim()) {
throw new Error(`Provider "${provider.name}": Authorization rule claim cannot be empty`);
}
if (!rule.operator) {
throw new Error(`Provider "${provider.name}": Authorization rule operator is required`);
}
if (!rule.value || rule.value.length === 0 || rule.value.every((v) => !v || !v.trim())) {
throw new Error(
`Provider "${provider.name}": Authorization rule for claim "${rule.claim}" must have at least one non-empty value`
);
// Validate each rule has valid values (only if rules exist)
if (provider.authorizationRules && provider.authorizationRules.length > 0) {
for (const rule of provider.authorizationRules) {
if (!rule.claim || !rule.claim.trim()) {
throw new Error(
`Provider "${provider.name}": Authorization rule claim cannot be empty`
);
}
if (!rule.operator) {
throw new Error(
`Provider "${provider.name}": Authorization rule operator is required`
);
}
if (!rule.value || rule.value.length === 0 || rule.value.every((v) => !v || !v.trim())) {
throw new Error(
`Provider "${provider.name}": Authorization rule for claim "${rule.claim}" must have at least one non-empty value`
);
}
}
}
@@ -370,12 +376,13 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
}),
};
// Validate authorization rules for ALL providers including unraid.net
// Validate authorization rules for providers that have them
for (const provider of processedConfig.providers) {
if (!provider.authorizationRules || provider.authorizationRules.length === 0) {
throw new Error(
`Provider "${provider.name}" requires authorization rules. Please configure who can access your server.`
this.logger.warn(
`Provider "${provider.name}" has no authorization rules and will be ignored. Configure authorization rules to enable this provider.`
);
continue;
}
// Validate each rule has valid values
@@ -565,9 +572,9 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
providersSlice.elements[0].elements.unshift(
createLabeledControl({
scope: '#/properties/sso/properties/defaultAllowedOrigins',
label: 'Allowed Redirect Origins',
label: 'Allowed OIDC Redirect Origins',
description:
'Add trusted origins here when accessing Unraid through custom ports, reverse proxies, or Tailscale. Each origin should include the protocol and optionally a port (e.g., https://unraid.local:8443)',
'Add trusted origins for OIDC redirection. These are URLs that the OIDC provider can redirect to after authentication when accessing Unraid through custom ports, reverse proxies, or Tailscale. Each origin should include the protocol and optionally a port (e.g., https://unraid.local:8443)',
controlOptions: {
format: 'array',
inputType: 'text',

View File

@@ -1,6 +0,0 @@
Menu="ManagementAccess:160"
Title="API Config Download"
Icon="icon-download"
Tag="download"
---
<unraid-config-download />

View File

@@ -153,8 +153,8 @@ const addItem = () => {
};
const removeItem = (index: number) => {
const newItems = [...items.value];
newItems.splice(index, 1);
// Create a completely new array by filtering out the item
const newItems = items.value.filter((_, i) => i !== index);
items.value = newItems;
};

View File

@@ -24,8 +24,8 @@ const addItem = () => {
};
const removeItem = (index: number) => {
const newItems = [...items.value];
newItems.splice(index, 1);
// Create a completely new array by filtering out the item
const newItems = items.value.filter((_, i) => i !== index);
items.value = newItems;
};

View File

@@ -52,7 +52,6 @@ const { modalVisible, editingKey, isAuthorizationMode, authorizationData, create
// This will be transformed into CreateApiKeyInput or UpdateApiKeyInput
interface FormData extends Partial<CreateApiKeyInput> {
keyName?: string; // Used in authorization mode
authorizationType?: 'roles' | 'groups' | 'custom';
permissionGroups?: string[];
permissionPresets?: string; // For the preset dropdown
customPermissions?: Array<{
@@ -74,7 +73,6 @@ const formSchema = ref<ApiKeyFormSettings | null>(null);
const formData = ref<FormData>({
customPermissions: [],
roles: [],
authorizationType: 'roles',
} as FormData);
const formValid = ref(false);
@@ -83,10 +81,14 @@ const { copyWithNotification, copied } = useClipboardWithToast();
// Computed property to transform formData permissions for the EffectivePermissions component
const formDataPermissions = computed(() => {
if (!formData.value.customPermissions) return [];
// Explicitly depend on the array length to ensure reactivity when going to/from empty
const permissions = formData.value.customPermissions;
const permissionCount = permissions?.length ?? 0;
if (!permissions || permissionCount === 0) return [];
// Flatten the resources array into individual permission entries
return formData.value.customPermissions.flatMap((perm) =>
return permissions.flatMap((perm) =>
perm.resources.map((resource) => ({
resource,
actions: perm.actions, // Already string[] which can be AuthAction values
@@ -141,6 +143,7 @@ const loadFormSchema = () => {
// For new keys, initialize with empty data
formData.value = {
customPermissions: [],
roles: [],
};
// Set formValid to true initially for new keys
// JsonForms will update this if there are validation errors
@@ -250,7 +253,6 @@ const populateFormFromExistingKey = async () => {
formData.value = {
name: fragmentKey.name,
description: fragmentKey.description || '',
authorizationType: fragmentKey.roles.length > 0 ? 'roles' : 'custom',
roles: [...fragmentKey.roles],
customPermissions,
};
@@ -303,7 +305,10 @@ const transformFormDataForApi = (): CreateApiKeyInput => {
const close = () => {
apiKeyStore.hideModal();
formData.value = {} as FormData; // Reset to empty object
formData.value = {
customPermissions: [],
roles: [],
} as FormData;
};
// Handle form submission
@@ -356,7 +361,10 @@ async function upsertKey() {
}
apiKeyStore.hideModal();
formData.value = {} as FormData; // Reset to empty object
formData.value = {
customPermissions: [],
roles: [],
} as FormData;
} catch (error) {
console.error('Error in upsertKey:', error);
} finally {

View File

@@ -34,6 +34,7 @@ const state = reactive({
loadedContentChunks: [] as { content: string; startLine: number }[],
currentStartLine: undefined as number | undefined,
isLoadingMore: false,
isRefreshing: false,
isAtTop: false,
canLoadMore: false,
initialLoadComplete: false,
@@ -104,11 +105,25 @@ watch(
const effectiveStartLine = startLine || 1;
if (state.isLoadingMore) {
// Loading more historical content - prepend to existing chunks
state.loadedContentChunks.unshift({ content, startLine: effectiveStartLine });
state.isLoadingMore = false;
nextTick(() => (state.canLoadMore = true));
} else if (state.isRefreshing) {
// Refreshing - replace all content and reset state
state.loadedContentChunks = [{ content, startLine: effectiveStartLine }];
state.isRefreshing = false;
state.currentStartLine = undefined;
state.isAtTop = false;
state.initialLoadComplete = true;
nextTick(() => {
forceScrollToBottom();
setTimeout(() => (state.canLoadMore = true), 300);
});
} else {
// Initial load - replace all content
state.loadedContentChunks = [{ content, startLine: effectiveStartLine }];
nextTick(() => {
@@ -235,16 +250,6 @@ const downloadLogFile = async () => {
}
};
// Clear all state to initial values
const clearState = () => {
state.loadedContentChunks = [];
state.currentStartLine = undefined;
state.isAtTop = false;
state.canLoadMore = false;
state.initialLoadComplete = false;
state.isLoadingMore = false;
};
// Helper function to start log subscription
const startLogSubscription = () => {
if (!props.logFilePath) return;
@@ -297,10 +302,12 @@ const startLogSubscription = () => {
}
};
// Refresh logs
// Refresh logs with full reset
const refreshLogContent = async () => {
// Clear the state
clearState();
// Set refresh flag to indicate we're refreshing
state.isRefreshing = true;
state.isLoadingMore = false;
state.canLoadMore = false;
// Refetch with explicit variables to ensure we get the latest logs
await refetchLogContent({
@@ -310,13 +317,7 @@ const refreshLogContent = async () => {
});
// Restart the subscription with the same variables used for refetch
// Note: subscribeToMore in Vue Apollo doesn't return an unsubscribe function
// The previous subscription is automatically replaced when calling subscribeToMore again
startLogSubscription();
nextTick(() => {
forceScrollToBottom();
});
};
watch(() => props.logFilePath, refreshLogContent);