diff --git a/README.md b/README.md
index 433688b..154f575 100644
--- a/README.md
+++ b/README.md
@@ -135,7 +135,7 @@ starts with the computer, before anyone signs in, and that the window then drive
- **Source rules** for the devices that bend it further: right-click a message, say how that
device is read (any import format, a regex), fix its hostname, application, clock, severity
or encoding, and watch its latest messages change before saving — then read the stored
- history again with it
+ history again with it. Start from a template, save your own, and export them to share
- **Mutual TLS** where senders must prove who they are
- **Allowed sources** by IP literal or CIDR (hygiene, not authentication — see the note below)
- **Per-IP connection cap** so one host cannot occupy every slot
diff --git a/app_sources.go b/app_sources.go
new file mode 100644
index 0000000..fcaef64
--- /dev/null
+++ b/app_sources.go
@@ -0,0 +1,121 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "github.com/google/uuid"
+
+ "SyslogStudio/internal/models"
+ "SyslogStudio/internal/sources"
+
+ wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
+)
+
+// --- Source rules: templates, and files to share them ------------------------
+//
+// Window-only: the templates are the same everywhere, and a file is read or
+// written where the person sits. Rules read from a file are saved through the
+// Backend like any other, so they reach the service the window drives.
+
+// GetSourceTemplates returns the rules offered to start from: those that
+// come with SyslogStudio, then those this person saved.
+func (a *App) GetSourceTemplates() []models.SourceTemplate {
+ return append(sources.Templates(), a.prefs.LoadSourceTemplates()...)
+}
+
+// SaveSourceTemplate saves a rule's way of reading as one of this person's
+// templates — a new one, or in place of the one with its ID — and returns it.
+// The devices the rule names are not part of it.
+func (a *App) SaveSourceTemplate(t models.SourceTemplate) (models.SourceTemplate, error) {
+ t.Name = strings.TrimSpace(t.Name)
+ t.Rule = t.Rule.AsTemplate()
+ if err := sources.CheckTemplate(t); err != nil {
+ return models.SourceTemplate{}, err
+ }
+ mine := a.prefs.LoadSourceTemplates()
+ replaced := false
+ for i := range mine {
+ if t.ID != "" && mine[i].ID == t.ID {
+ mine[i], replaced = t, true
+ }
+ }
+ if !replaced {
+ t.ID = "mine-" + uuid.NewString()
+ mine = append(mine, t)
+ }
+ a.prefs.SaveSourceTemplates(mine)
+ return t, nil
+}
+
+// DeleteSourceTemplate removes one of this person's templates. The rules
+// started from it keep what they took.
+func (a *App) DeleteSourceTemplate(id string) {
+ mine := a.prefs.LoadSourceTemplates()
+ kept := mine[:0]
+ for _, t := range mine {
+ if t.ID != id {
+ kept = append(kept, t)
+ }
+ }
+ a.prefs.SaveSourceTemplates(kept)
+}
+
+// ExportSourceRules asks where, then writes the rules there, with this
+// person's templates. "" when the person cancelled.
+func (a *App) ExportSourceRules(rules []models.SourceRule) (string, error) {
+ data, err := sources.EncodeFile(rules, a.prefs.LoadSourceTemplates())
+ if err != nil {
+ return "", err
+ }
+ path, err := wailsRuntime.SaveFileDialog(a.ctx, wailsRuntime.SaveDialogOptions{
+ Title: "Export source rules",
+ DefaultFilename: "syslogstudio-source-rules.json",
+ Filters: []wailsRuntime.FileFilter{{DisplayName: "Source rules (*.json)", Pattern: "*.json"}},
+ })
+ if err != nil || path == "" {
+ return "", err
+ }
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ return "", fmt.Errorf("failed to write the rules: %w", err)
+ }
+ return path, nil
+}
+
+// ReadSourceRulesFile asks for a file of exported rules and reads it, every
+// rule and template checked as it would be saved. The templates are added to
+// this person's; the rules are returned, for the window to save — through the
+// Backend, so that they reach the engine it drives. Nothing when the person
+// cancelled.
+func (a *App) ReadSourceRulesFile() (sources.RuleFile, error) {
+ path, err := wailsRuntime.OpenFileDialog(a.ctx, wailsRuntime.OpenDialogOptions{
+ Title: "Import source rules",
+ Filters: []wailsRuntime.FileFilter{{DisplayName: "Source rules (*.json)", Pattern: "*.json"}},
+ })
+ if err != nil || path == "" {
+ return sources.RuleFile{}, err
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return sources.RuleFile{}, err
+ }
+ defer f.Close()
+ data, err := io.ReadAll(io.LimitReader(f, sources.MaxFileSize+1))
+ if err != nil {
+ return sources.RuleFile{}, err
+ }
+ file, err := sources.DecodeFile(data)
+ if err != nil {
+ return sources.RuleFile{}, err
+ }
+ for i, t := range file.Templates {
+ saved, err := a.SaveSourceTemplate(t)
+ if err != nil {
+ return sources.RuleFile{}, err
+ }
+ file.Templates[i] = saved
+ }
+ return file, nil
+}
diff --git a/app_sources_test.go b/app_sources_test.go
new file mode 100644
index 0000000..c50a5e7
--- /dev/null
+++ b/app_sources_test.go
@@ -0,0 +1,55 @@
+package main
+
+import (
+ "testing"
+
+ "SyslogStudio/internal/models"
+ "SyslogStudio/internal/sources"
+ "SyslogStudio/internal/storage"
+)
+
+// A person's templates: saved without the devices a rule names, offered after
+// those that come with SyslogStudio, replaced by ID, deleted.
+func TestSourceTemplates_Mine(t *testing.T) {
+ a := newAppIn(storage.NewConfigStoreAt(t.TempDir()))
+ builtIn := len(sources.Templates())
+
+ rule := models.SourceRule{ID: "source-1", Name: "Lobby cameras", Enabled: true,
+ Match: models.SourceMatch{Sources: []string{"10.10.7.0/24"}},
+ Format: models.ImportFormat{Mode: models.ImportCustom, Pattern: `^(?P
diff --git a/frontend/screenshots/bridge/App.js b/frontend/screenshots/bridge/App.js
index f9cfe91..eb6a6cb 100644
--- a/frontend/screenshots/bridge/App.js
+++ b/frontend/screenshots/bridge/App.js
@@ -33,7 +33,7 @@ import {
IMPORT_PREVIEW,
SIMULATOR_CONFIG, SIMULATOR_STATUS,
NETWORK_INTERFACES, CERT_INFO,
- BACKGROUND_SETTINGS, SYSTEM_SERVICE, SERVICE_LINK, SOURCE_RULES,
+ BACKGROUND_SETTINGS, SYSTEM_SERVICE, SERVICE_LINK, SOURCE_RULES, SOURCE_TEMPLATES,
BACKUP_CONFIG, BACKUP_STATUS, BACKUP_FILES, BACKUP_LISTING, RECOVERY_KEY,
} from '../fixtures.js';
@@ -96,6 +96,7 @@ let recoveryKey = clone(RECOVERY_KEY);
let updateConfig = { checkOnStartup: true, channel: 'stable', skippedVersion: '' };
let closeAction = 'ask';
let sourceRules = clone(SOURCE_RULES);
+let sourceTemplates = clone(SOURCE_TEMPLATES);
/**
* What a rule makes of a message, as far as a page can say without the
@@ -362,6 +363,15 @@ const HANDLERS = {
return Promise.resolve(null);
},
TrySourceRule: (rule, samples) => Promise.resolve(trySource(rule, samples || [])),
+ GetSourceTemplates: () => Promise.resolve(clone(sourceTemplates)),
+ SaveSourceTemplate: (t) => {
+ const saved = { ...t, id: t.id || `mine-${Date.now()}` };
+ sourceTemplates = [...sourceTemplates.filter((x) => x.id !== saved.id), saved];
+ return Promise.resolve(clone(saved));
+ },
+ DeleteSourceTemplate: (id) => { sourceTemplates = sourceTemplates.filter((t) => t.id !== id); return Promise.resolve(null); },
+ ExportSourceRules: () => Promise.resolve('C:\\Users\\operator\\Documents\\syslogstudio-source-rules.json'),
+ ReadSourceRulesFile: () => Promise.resolve({ rules: [], templates: [] }),
// What reading the stored messages again did last: a page cannot do it.
GetHistoryReread: () => Promise.resolve({ running: false, total: 1284409, read: 1284409, changed: 3216 }),
RereadHistory: () => Promise.resolve(null),
diff --git a/frontend/screenshots/fixtures.js b/frontend/screenshots/fixtures.js
index 43b4c66..6e0d567 100644
--- a/frontend/screenshots/fixtures.js
+++ b/frontend/screenshots/fixtures.js
@@ -656,6 +656,21 @@ export const BACKUP_LISTING = { files: BACKUP_FILES, problems: [] };
* sends no hostname. Read the way the engine reads them, as far as a page can
* pretend to (bridge/App.js, TrySourceRule).
*/
+/** The templates that come with SyslogStudio (sources.Templates), and one saved. */
+export const SOURCE_TEMPLATES = [
+ { id: 'no-hostname', rule: { format: { mode: 'auto' }, noHostname: true } },
+ { id: 'camera', rule: { format: { mode: 'auto' }, noHostname: true, levelFrom: 'message' } },
+ { id: 'level-in-text', rule: { format: { mode: 'auto' }, levelFrom: 'message' } },
+ { id: 'json', rule: { format: { mode: 'json' } } },
+ { id: 'windows-1252', rule: { format: { mode: 'auto' }, encoding: 'windows-1252' } },
+ { id: 'bad-clock', rule: { format: { mode: 'auto' }, arrivalTime: true } },
+ { id: 'own-format', rule: { format: { mode: 'custom', pattern: '^(?P