Skip to content

Commit 97b52ce

Browse files
committed
Support storage config edition
1 parent 7e580fd commit 97b52ce

7 files changed

Lines changed: 480 additions & 14 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
@if (data$ | async; as data) {
2+
<div>
3+
<!-- Header with search and add button -->
4+
<div class="flex items-center justify-between gap-2 mb-4">
5+
<input
6+
type="text"
7+
[(ngModel)]="searchPrefix"
8+
(ngModelChange)="onSearchChange($event)"
9+
placeholder="Search by prefix..."
10+
class="input-outline flex-1 max-w-md"
11+
/>
12+
<button (click)="openNew()" class="btn-blue">Add Key</button>
13+
</div>
14+
15+
<!-- Data table -->
16+
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
17+
<table class="w-full">
18+
<thead class="bg-gray-50 dark:bg-gray-800">
19+
<tr>
20+
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">Key</th>
21+
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">Value</th>
22+
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">Expires</th>
23+
<th class="px-4 py-3 text-right text-sm font-medium text-gray-500 dark:text-gray-400">Actions</th>
24+
</tr>
25+
</thead>
26+
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
27+
@if (items.length === 0) {
28+
<tr>
29+
<td colspan="4" class="px-4 py-8 text-center text-gray-500">No keys found. Click "Add Key" to create one.</td>
30+
</tr>
31+
} @else { @for (item of items; track item.key) {
32+
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
33+
<td class="px-4 py-3 font-mono text-sm">{{ item.key }}</td>
34+
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 max-w-md truncate">
35+
{{ truncate(item.value) }}
36+
</td>
37+
<td class="px-4 py-3 text-sm text-gray-500">{{ formatDate(item.expiresAt) }}</td>
38+
<td class="px-4 py-3 text-right">
39+
<button (click)="openEdit(item)" class="text-blue-600 hover:underline text-sm mr-3">Edit</button>
40+
<button (click)="confirmDelete(item.key)" class="text-red-600 hover:underline text-sm">Delete</button>
41+
</td>
42+
</tr>
43+
} }
44+
</tbody>
45+
</table>
46+
47+
<!-- Load more -->
48+
@if (hasMore) {
49+
<div class="p-4 border-t border-gray-200 dark:border-gray-700 text-center">
50+
<button (click)="loadMore()" class="btn-light">Load More</button>
51+
</div>
52+
}
53+
</div>
54+
</div>
55+
} @else {
56+
<div class="text-center py-8 text-gray-500">Loading...</div>
57+
}
58+
59+
<!-- Edit Modal -->
60+
@if (editingItem) {
61+
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeEdit()">
62+
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-lg mx-4" (click)="$event.stopPropagation()">
63+
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
64+
<h3 class="text-lg font-semibold">{{ editingItem.isNew ? 'Add Key' : 'Edit Key' }}</h3>
65+
</div>
66+
<div class="p-4 space-y-4">
67+
<div>
68+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Key</label>
69+
<input
70+
type="text"
71+
[(ngModel)]="editingItem.key"
72+
[disabled]="!editingItem.isNew"
73+
class="input-outline w-full font-mono"
74+
placeholder="my-key"
75+
/>
76+
</div>
77+
<div>
78+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Value</label>
79+
<textarea [(ngModel)]="editingItem.value" class="input-outline w-full font-mono h-32" placeholder="value..."></textarea>
80+
</div>
81+
<div>
82+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Expires in (seconds)</label>
83+
<input
84+
type="number"
85+
[(ngModel)]="editingItem.expiresIn"
86+
class="input-outline w-full font-mono"
87+
placeholder="Leave empty for no expiration"
88+
/>
89+
</div>
90+
</div>
91+
<div class="p-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
92+
<button (click)="closeEdit()" class="btn-light">Cancel</button>
93+
<button (click)="save()" class="btn-blue">Save</button>
94+
</div>
95+
</div>
96+
</div>
97+
}
98+
99+
<!-- Delete Confirmation Modal -->
100+
@if (deleteConfirm) {
101+
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="cancelDelete()">
102+
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-sm mx-4" (click)="$event.stopPropagation()">
103+
<div class="p-4">
104+
<h3 class="text-lg font-semibold mb-2">Delete Key?</h3>
105+
<p class="text-gray-600 dark:text-gray-400">
106+
Are you sure you want to delete
107+
<code class="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">{{ deleteConfirm }}</code
108+
>?
109+
</p>
110+
</div>
111+
<div class="p-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
112+
<button (click)="cancelDelete()" class="btn-light">Cancel</button>
113+
<button (click)="doDelete()" class="btn-outline-red">Delete</button>
114+
</div>
115+
</div>
116+
</div>
117+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { Component, Input, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
2+
import { FormsModule } from '@angular/forms';
3+
import { CommonModule } from '@angular/common';
4+
import { BehaviorSubject, Observable, Subject, debounceTime, distinctUntilChanged, merge, switchMap, tap } from 'rxjs';
5+
import { KvDataService, KvDataItem, KvDataListResponse } from '~/app/services/kv-data.service';
6+
7+
@Component({
8+
selector: 'app-kv-data-browser',
9+
standalone: true,
10+
imports: [CommonModule, FormsModule],
11+
templateUrl: './kv-data-browser.component.html',
12+
changeDetection: ChangeDetectionStrategy.OnPush
13+
})
14+
export class KvDataBrowserComponent implements OnInit {
15+
@Input({ required: true }) namespaceId!: string;
16+
17+
private readonly refresh$ = new BehaviorSubject<{ cursor?: string }>({});
18+
private readonly search$ = new Subject<string>();
19+
20+
public readonly data$: Observable<KvDataListResponse>;
21+
public items: KvDataItem[] = [];
22+
public cursor: string | null = null;
23+
public hasMore = false;
24+
25+
public searchPrefix = '';
26+
public editingItem: { key: string; value: string; expiresIn: number | null; isNew: boolean } | null = null;
27+
public deleteConfirm: string | null = null;
28+
29+
constructor(
30+
private kvDataService: KvDataService,
31+
private cdr: ChangeDetectorRef
32+
) {
33+
const debouncedSearch$ = this.search$.pipe(
34+
debounceTime(300),
35+
distinctUntilChanged()
36+
);
37+
38+
this.data$ = merge(
39+
this.refresh$,
40+
debouncedSearch$.pipe(tap(() => this.cursor = null))
41+
).pipe(
42+
switchMap(() =>
43+
this.kvDataService.list(this.namespaceId, {
44+
prefix: this.searchPrefix || undefined,
45+
cursor: this.cursor ?? undefined,
46+
limit: 50
47+
}).pipe(
48+
tap((res) => {
49+
if (!this.cursor) {
50+
this.items = res.items;
51+
} else {
52+
this.items = [...this.items, ...res.items];
53+
}
54+
55+
this.cursor = res.cursor;
56+
this.hasMore = res.hasMore;
57+
this.cdr.markForCheck();
58+
})
59+
)
60+
)
61+
);
62+
}
63+
64+
ngOnInit() {
65+
// Initial load triggered by template subscription
66+
}
67+
68+
onSearchChange(value: string) {
69+
this.search$.next(value);
70+
}
71+
72+
loadMore() {
73+
if (this.cursor) {
74+
this.refresh$.next({});
75+
}
76+
}
77+
78+
openNew() {
79+
this.editingItem = { key: '', value: '', expiresIn: null, isNew: true };
80+
}
81+
82+
openEdit(item: KvDataItem) {
83+
this.editingItem = { key: item.key, value: item.value, expiresIn: null, isNew: false };
84+
}
85+
86+
closeEdit() {
87+
this.editingItem = null;
88+
}
89+
90+
save() {
91+
if (!this.editingItem) return;
92+
93+
this.kvDataService.put(
94+
this.namespaceId,
95+
this.editingItem.key,
96+
this.editingItem.value,
97+
this.editingItem.expiresIn ?? undefined
98+
).subscribe({
99+
next: () => {
100+
this.closeEdit();
101+
this.cursor = null;
102+
this.refresh$.next({});
103+
},
104+
error: (err) => {
105+
console.error('Failed to save:', err);
106+
}
107+
});
108+
}
109+
110+
confirmDelete(key: string) {
111+
this.deleteConfirm = key;
112+
}
113+
114+
cancelDelete() {
115+
this.deleteConfirm = null;
116+
}
117+
118+
doDelete() {
119+
if (!this.deleteConfirm) return;
120+
121+
this.kvDataService.delete(this.namespaceId, this.deleteConfirm).subscribe({
122+
next: () => {
123+
this.deleteConfirm = null;
124+
this.cursor = null;
125+
this.refresh$.next({});
126+
},
127+
error: (err) => {
128+
console.error('Failed to delete:', err);
129+
}
130+
});
131+
}
132+
133+
truncate(value: string, maxLength = 100): string {
134+
if (value.length <= maxLength) return value;
135+
return value.slice(0, maxLength) + '...';
136+
}
137+
138+
formatDate(date: string | null): string {
139+
if (!date) return '-';
140+
return new Date(date).toLocaleString();
141+
}
142+
}

src/app/modules/kv/pages/kv-overview/kv-overview.page.html

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
<div>
22
@if (kv$ | async; as kv) {
3+
<!-- Data Browser -->
4+
<div class="card shadow-sm mt-8">
5+
<div class="p-4">
6+
<h4 class="mb-4">Data Browser</h4>
7+
<app-kv-data-browser [namespaceId]="kvId" />
8+
</div>
9+
</div>
10+
11+
<!-- Namespace Info -->
312
<div class="card shadow-sm mt-8">
413
<div class="inline-block p-4 w-full">
514
<h4>Namespace Info</h4>
@@ -16,17 +25,7 @@ <h4>Namespace Info</h4>
1625
</div>
1726
</div>
1827

19-
<div class="card shadow-sm mt-8">
20-
<div class="inline-block p-4 w-full">
21-
<h4>Binding Setup</h4>
22-
<div class="mt-4">
23-
<p class="text-sm text-gray-600 dark:text-gray-400">
24-
To use this KV namespace in your worker, add a KV binding in your environment settings with the namespace ID above.
25-
</p>
26-
</div>
27-
</div>
28-
</div>
29-
28+
<!-- Usage -->
3029
<div class="card shadow-sm mt-8">
3130
<div class="inline-block p-4 w-full">
3231
<h4>Usage</h4>

src/app/modules/kv/pages/kv-overview/kv-overview.page.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,24 @@ import { Observable } from 'rxjs';
44
import { Resolved } from '~/app/interfaces/resolved';
55
import { SharedModule } from '~/app/shared/shared.module';
66
import { KvService } from '~/services/kv.service';
7+
import { KvDataBrowserComponent } from '../../components/kv-data-browser.component';
78
import type { IKvNamespace } from '@openworkers/api-types';
89

910
@Component({
1011
standalone: true,
11-
imports: [SharedModule],
12+
imports: [SharedModule, KvDataBrowserComponent],
1213
templateUrl: './kv-overview.page.html'
1314
})
1415
export default class KvOverviewPage {
1516
public readonly kv$: Observable<IKvNamespace>;
17+
public readonly kvId: string;
1618

1719
constructor(
1820
route: ActivatedRoute,
1921
private kvService: KvService
2022
) {
2123
const kv = route.parent?.snapshot.data['kv'] as Resolved<IKvNamespace>;
24+
this.kvId = kv.id;
2225

2326
// Subscribe to the cached observable to get live updates
2427
this.kv$ = kv.asObservable?.() ?? this.kvService.findById(kv.id);

0 commit comments

Comments
 (0)